package imgcache import ( "bytes" "context" "database/sql" "os" "testing" "time" _ "modernc.org/sqlite" ) func setupTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open test db: %v", err) } // Create schema schema := ` CREATE TABLE source_content ( content_hash TEXT PRIMARY KEY, content_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE source_metadata ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_host TEXT NOT NULL, source_path TEXT NOT NULL, source_query TEXT NOT NULL DEFAULT '', path_hash TEXT NOT NULL, content_hash TEXT, status_code INTEGER NOT NULL, content_type TEXT, response_headers TEXT, fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME, etag TEXT, last_modified TEXT, UNIQUE(source_host, source_path, source_query) ); CREATE TABLE output_content ( content_hash TEXT PRIMARY KEY, content_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE request_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, cache_key TEXT NOT NULL UNIQUE, source_metadata_id INTEGER NOT NULL, output_hash TEXT NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, format TEXT NOT NULL, quality INTEGER NOT NULL DEFAULT 85, fit_mode TEXT NOT NULL DEFAULT 'cover', fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP, access_count INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE negative_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_host TEXT NOT NULL, source_path TEXT NOT NULL, source_query TEXT NOT NULL DEFAULT '', status_code INTEGER NOT NULL, error_message TEXT, fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, UNIQUE(source_host, source_path, source_query) ); CREATE TABLE cache_stats ( id INTEGER PRIMARY KEY CHECK (id = 1), hit_count INTEGER NOT NULL DEFAULT 0, miss_count INTEGER NOT NULL DEFAULT 0, upstream_fetch_count INTEGER NOT NULL DEFAULT 0, upstream_fetch_bytes INTEGER NOT NULL DEFAULT 0, transform_count INTEGER NOT NULL DEFAULT 0, last_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO cache_stats (id) VALUES (1); ` if _, err := db.Exec(schema); err != nil { t.Fatalf("failed to create schema: %v", err) } return db } func setupTestCache(t *testing.T) (*Cache, string) { t.Helper() tmpDir := t.TempDir() db := setupTestDB(t) cache, err := NewCache(db, CacheConfig{ StateDir: tmpDir, CacheTTL: time.Hour, NegativeTTL: 5 * time.Minute, HotCacheSize: 100, HotCacheEnabled: true, }) if err != nil { t.Fatalf("failed to create cache: %v", err) } return cache, tmpDir } func TestCache_LookupMiss(t *testing.T) { cache, _ := setupTestCache(t) ctx := context.Background() req := &ImageRequest{ SourceHost: "cdn.example.com", SourcePath: "/photos/cat.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Quality: 85, FitMode: FitCover, } result, err := cache.Lookup(ctx, req) if err != nil { t.Fatalf("Lookup() error = %v", err) } if result.Hit { t.Error("Lookup() hit = true, want false for cache miss") } if result.CacheStatus != CacheMiss { t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheMiss) } } func TestCache_StoreAndLookup(t *testing.T) { cache, _ := setupTestCache(t) ctx := context.Background() req := &ImageRequest{ SourceHost: "cdn.example.com", SourcePath: "/photos/cat.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Quality: 85, FitMode: FitCover, } // Store source content sourceContent := []byte("fake jpeg data") fetchResult := &FetchResult{ ContentType: "image/jpeg", Headers: map[string][]string{"Content-Type": {"image/jpeg"}}, } contentHash, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult) if err != nil { t.Fatalf("StoreSource() error = %v", err) } if contentHash == "" { t.Error("StoreSource() returned empty hash") } // Get source metadata ID metaID, err := cache.GetSourceMetadataID(ctx, req) if err != nil { t.Fatalf("GetSourceMetadataID() error = %v", err) } // Store output content outputContent := []byte("fake webp data") _, err = cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp") if err != nil { t.Fatalf("StoreOutput() error = %v", err) } // Now lookup should hit result, err := cache.Lookup(ctx, req) if err != nil { t.Fatalf("Lookup() error = %v", err) } if !result.Hit { t.Error("Lookup() hit = false, want true after store") } if result.CacheStatus != CacheHit { t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheHit) } if result.OutputHash == "" { t.Error("Lookup() returned empty output hash") } } func TestCache_NegativeCache(t *testing.T) { cache, _ := setupTestCache(t) ctx := context.Background() req := &ImageRequest{ SourceHost: "cdn.example.com", SourcePath: "/photos/notfound.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, } // Store negative cache entry err := cache.StoreNegative(ctx, req, 404, "not found") if err != nil { t.Fatalf("StoreNegative() error = %v", err) } // Lookup should return negative cache error _, err = cache.Lookup(ctx, req) if err != ErrNegativeCache { t.Errorf("Lookup() error = %v, want ErrNegativeCache", err) } } func TestCache_NegativeCacheExpiry(t *testing.T) { tmpDir := t.TempDir() db := setupTestDB(t) // Very short negative TTL for testing cache, err := NewCache(db, CacheConfig{ StateDir: tmpDir, CacheTTL: time.Hour, NegativeTTL: 1 * time.Millisecond, HotCacheSize: 100, HotCacheEnabled: true, }) if err != nil { t.Fatalf("failed to create cache: %v", err) } ctx := context.Background() req := &ImageRequest{ SourceHost: "cdn.example.com", SourcePath: "/photos/expired.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, } // Store negative cache entry err = cache.StoreNegative(ctx, req, 404, "not found") if err != nil { t.Fatalf("StoreNegative() error = %v", err) } // Wait for expiry time.Sleep(10 * time.Millisecond) // Lookup should return miss (not negative cache) after expiry result, err := cache.Lookup(ctx, req) if err != nil { t.Fatalf("Lookup() error = %v, want nil after expiry", err) } if result.Hit { t.Error("Lookup() hit = true, want false after negative cache expiry") } } func TestCache_HotCache(t *testing.T) { cache, _ := setupTestCache(t) ctx := context.Background() req := &ImageRequest{ SourceHost: "cdn.example.com", SourcePath: "/photos/hot.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Quality: 85, FitMode: FitCover, } // Store content sourceContent := []byte("source data") fetchResult := &FetchResult{ ContentType: "image/jpeg", Headers: map[string][]string{}, } _, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult) if err != nil { t.Fatalf("StoreSource() error = %v", err) } metaID, _ := cache.GetSourceMetadataID(ctx, req) outputContent := []byte("output data") _, err = cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp") if err != nil { t.Fatalf("StoreOutput() error = %v", err) } // First lookup populates hot cache result1, err := cache.Lookup(ctx, req) if err != nil { t.Fatalf("Lookup() first error = %v", err) } if !result1.Hit { t.Error("Lookup() first hit = false") } // Second lookup should use hot cache result2, err := cache.Lookup(ctx, req) if err != nil { t.Fatalf("Lookup() second error = %v", err) } if !result2.Hit { t.Error("Lookup() second hit = false") } if result1.OutputHash != result2.OutputHash { t.Error("Lookup() returned different hashes") } } func TestCache_GetOutput(t *testing.T) { cache, _ := setupTestCache(t) ctx := context.Background() req := &ImageRequest{ SourceHost: "cdn.example.com", SourcePath: "/photos/output.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP, Quality: 85, FitMode: FitCover, } // Store content sourceContent := []byte("source") fetchResult := &FetchResult{ContentType: "image/jpeg", Headers: map[string][]string{}} _, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult) if err != nil { t.Fatalf("StoreSource() error = %v", err) } metaID, err := cache.GetSourceMetadataID(ctx, req) if err != nil { t.Fatalf("GetSourceMetadataID() error = %v", err) } outputContent := []byte("the actual output content") outputHash, err := cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp") if err != nil { t.Fatalf("StoreOutput() error = %v", err) } if outputHash == "" { t.Fatal("StoreOutput() returned empty hash") } // Lookup to get hash result, err := cache.Lookup(ctx, req) if err != nil { t.Fatalf("Lookup() error = %v", err) } // Get output content reader, err := cache.GetOutput(result.OutputHash) if err != nil { t.Fatalf("GetOutput() error = %v", err) } defer reader.Close() buf := make([]byte, 100) n, _ := reader.Read(buf) if !bytes.Equal(buf[:n], outputContent) { t.Errorf("GetOutput() content = %q, want %q", buf[:n], outputContent) } } func TestCache_Stats(t *testing.T) { cache, _ := setupTestCache(t) ctx := context.Background() // Increment some stats cache.IncrementStats(ctx, true, 0) cache.IncrementStats(ctx, true, 0) cache.IncrementStats(ctx, false, 1024) stats, err := cache.Stats(ctx) if err != nil { t.Fatalf("Stats() error = %v", err) } if stats.HitCount != 2 { t.Errorf("Stats() HitCount = %d, want 2", stats.HitCount) } if stats.MissCount != 1 { t.Errorf("Stats() MissCount = %d, want 1", stats.MissCount) } } func TestCache_CleanExpired(t *testing.T) { tmpDir := t.TempDir() db := setupTestDB(t) cache, _ := NewCache(db, CacheConfig{ StateDir: tmpDir, NegativeTTL: 1 * time.Millisecond, HotCacheEnabled: false, }) ctx := context.Background() // Insert expired negative cache entry directly _, err := db.ExecContext(ctx, ` INSERT INTO negative_cache (source_host, source_path, source_query, status_code, expires_at) VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour')) `) if err != nil { t.Fatalf("failed to insert test data: %v", err) } // Verify it exists var count int db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count) if count != 1 { t.Fatalf("expected 1 negative cache entry, got %d", count) } // Clean expired err = cache.CleanExpired(ctx) if err != nil { t.Fatalf("CleanExpired() error = %v", err) } // Verify it's gone db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count) if count != 0 { t.Errorf("expected 0 negative cache entries after clean, got %d", count) } } func TestCache_StorageDirectoriesCreated(t *testing.T) { tmpDir := t.TempDir() db := setupTestDB(t) _, err := NewCache(db, CacheConfig{ StateDir: tmpDir, HotCacheEnabled: false, }) if err != nil { t.Fatalf("NewCache() error = %v", err) } // Verify directories were created dirs := []string{ "cache/src-content", "cache/dst-content", "cache/src-metadata", } for _, dir := range dirs { path := tmpDir + "/" + dir if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("directory %s was not created", dir) } } }