From 9629139989ca452683a114c671d9cea64b68ac6c Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 03:39:23 -0800 Subject: [PATCH] Add tests for cache service Tests cover: lookup miss/hit, store source/output, negative caching, negative cache expiry, hot cache, output retrieval, stats, and cleanup. --- internal/imgcache/cache_test.go | 460 ++++++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 internal/imgcache/cache_test.go diff --git a/internal/imgcache/cache_test.go b/internal/imgcache/cache_test.go new file mode 100644 index 0000000..904dd95 --- /dev/null +++ b/internal/imgcache/cache_test.go @@ -0,0 +1,460 @@ +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{}} + _, _ = cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult) + + metaID, _ := cache.GetSourceMetadataID(ctx, req) + + outputContent := []byte("the actual output content") + _ = cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp") + + // Lookup to get hash + result, _ := cache.Lookup(ctx, req) + + // 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) + } + } +}