From 2cbafe374c6e7ff37c465b729a08ea7a8051315f Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 07:39:18 -0800 Subject: [PATCH] Add mock fetcher and service tests for imgcache Introduces Fetcher interface, mock implementation for testing, and ApplyMigrations helper for test database setup. --- internal/database/database.go | 74 ++++++ internal/imgcache/mock_fetcher.go | 115 ++++++++ internal/imgcache/service.go | 21 +- internal/imgcache/service_test.go | 407 +++++++++++++++++++++++++++++ internal/imgcache/testutil_test.go | 232 ++++++++++++++++ 5 files changed, 843 insertions(+), 6 deletions(-) create mode 100644 internal/imgcache/mock_fetcher.go create mode 100644 internal/imgcache/service_test.go create mode 100644 internal/imgcache/testutil_test.go diff --git a/internal/database/database.go b/internal/database/database.go index 2a5ea11..be80f1c 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -166,3 +166,77 @@ func (s *Database) runMigrations(ctx context.Context) error { func (s *Database) DB() *sql.DB { return s.db } + +// ApplyMigrations applies all migrations to the given database. +// This is useful for testing where you want to use the real schema +// without the full fx lifecycle. +func ApplyMigrations(db *sql.DB) error { + ctx := context.Background() + + // Create migrations tracking table + _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("failed to create migrations table: %w", err) + } + + // Get list of migration files + entries, err := schemaFS.ReadDir("schema") + if err != nil { + return fmt.Errorf("failed to read schema directory: %w", err) + } + + // Sort migration files by name (001.sql, 002.sql, etc.) + var migrations []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { + migrations = append(migrations, entry.Name()) + } + } + sort.Strings(migrations) + + // Apply each migration that hasn't been applied yet + for _, migration := range migrations { + version := strings.TrimSuffix(migration, filepath.Ext(migration)) + + // Check if already applied + var count int + err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", + version, + ).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check migration status: %w", err) + } + + if count > 0 { + continue + } + + // Read and apply migration + content, err := schemaFS.ReadFile(filepath.Join("schema", migration)) + if err != nil { + return fmt.Errorf("failed to read migration %s: %w", migration, err) + } + + _, err = db.ExecContext(ctx, string(content)) + if err != nil { + return fmt.Errorf("failed to apply migration %s: %w", migration, err) + } + + // Record migration as applied + _, err = db.ExecContext(ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", + version, + ) + if err != nil { + return fmt.Errorf("failed to record migration %s: %w", migration, err) + } + } + + return nil +} diff --git a/internal/imgcache/mock_fetcher.go b/internal/imgcache/mock_fetcher.go new file mode 100644 index 0000000..a309c4a --- /dev/null +++ b/internal/imgcache/mock_fetcher.go @@ -0,0 +1,115 @@ +package imgcache + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "strings" +) + +// MockFetcher implements the Fetcher interface using an embedded filesystem. +// Files are organized as: hostname/path/to/file.ext +// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg +type MockFetcher struct { + fs fs.FS +} + +// NewMockFetcher creates a new mock fetcher backed by the given filesystem. +func NewMockFetcher(fsys fs.FS) *MockFetcher { + return &MockFetcher{fs: fsys} +} + +// Fetch retrieves content from the mock filesystem. +func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) { + // Check context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Parse URL to get filesystem path + path, err := urlToFSPath(url) + if err != nil { + return nil, err + } + + // Open the file + f, err := m.fs.Open(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: status 404", ErrUpstreamError) + } + + return nil, fmt.Errorf("failed to open mock file: %w", err) + } + + // Get file info for content length + stat, err := f.Stat() + if err != nil { + _ = f.Close() + + return nil, fmt.Errorf("failed to stat mock file: %w", err) + } + + // Detect content type from extension + contentType := detectContentTypeFromPath(path) + + return &FetchResult{ + Content: f.(io.ReadCloser), + ContentLength: stat.Size(), + ContentType: contentType, + Headers: make(http.Header), + }, nil +} + +// urlToFSPath converts a URL to a filesystem path. +// https://example.com/images/photo.jpg -> example.com/images/photo.jpg +func urlToFSPath(rawURL string) (string, error) { + // Strip scheme + url := rawURL + if idx := strings.Index(url, "://"); idx != -1 { + url = url[idx+3:] + } + + // Remove query string + if idx := strings.Index(url, "?"); idx != -1 { + url = url[:idx] + } + + // Remove fragment + if idx := strings.Index(url, "#"); idx != -1 { + url = url[:idx] + } + + if url == "" { + return "", errors.New("empty URL path") + } + + return url, nil +} + +// detectContentTypeFromPath returns the MIME type based on file extension. +func detectContentTypeFromPath(path string) string { + path = strings.ToLower(path) + + switch { + case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"): + return "image/jpeg" + case strings.HasSuffix(path, ".png"): + return "image/png" + case strings.HasSuffix(path, ".gif"): + return "image/gif" + case strings.HasSuffix(path, ".webp"): + return "image/webp" + case strings.HasSuffix(path, ".avif"): + return "image/avif" + case strings.HasSuffix(path, ".svg"): + return "image/svg+xml" + default: + return "application/octet-stream" + } +} diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index 0905ece..d57011c 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -14,7 +14,7 @@ import ( // Service implements the ImageCache interface, orchestrating cache, fetcher, and processor. type Service struct { cache *Cache - fetcher *HTTPFetcher + fetcher Fetcher processor Processor signer *Signer whitelist *HostWhitelist @@ -25,8 +25,10 @@ type Service struct { type ServiceConfig struct { // Cache is the cache instance Cache *Cache - // FetcherConfig configures the upstream fetcher + // FetcherConfig configures the upstream fetcher (ignored if Fetcher is set) FetcherConfig *FetcherConfig + // Fetcher is an optional custom fetcher (for testing) + Fetcher Fetcher // SigningKey is the HMAC signing key (empty disables signing) SigningKey string // Whitelist is the list of hosts that don't require signatures @@ -41,9 +43,16 @@ func NewService(cfg *ServiceConfig) (*Service, error) { return nil, errors.New("cache is required") } - fetcherCfg := cfg.FetcherConfig - if fetcherCfg == nil { - fetcherCfg = DefaultFetcherConfig() + // Use custom fetcher if provided, otherwise create HTTP fetcher + var fetcher Fetcher + if cfg.Fetcher != nil { + fetcher = cfg.Fetcher + } else { + fetcherCfg := cfg.FetcherConfig + if fetcherCfg == nil { + fetcherCfg = DefaultFetcherConfig() + } + fetcher = NewHTTPFetcher(fetcherCfg) } var signer *Signer @@ -58,7 +67,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) { return &Service{ cache: cfg.Cache, - fetcher: NewHTTPFetcher(fetcherCfg), + fetcher: fetcher, processor: NewImageProcessor(), signer: signer, whitelist: NewHostWhitelist(cfg.Whitelist), diff --git a/internal/imgcache/service_test.go b/internal/imgcache/service_test.go new file mode 100644 index 0000000..06d5d33 --- /dev/null +++ b/internal/imgcache/service_test.go @@ -0,0 +1,407 @@ +package imgcache + +import ( + "context" + "io" + "testing" + "time" +) + +func TestService_Get_WhitelistedHost(t *testing.T) { + svc, fixtures := SetupTestService(t) + ctx := context.Background() + + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: "/images/photo.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + resp, err := svc.Get(ctx, req) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer resp.Content.Close() + + // Verify we got content + data, err := io.ReadAll(resp.Content) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + if len(data) == 0 { + t.Error("expected non-empty response") + } + + if resp.ContentType != "image/jpeg" { + t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg") + } +} + +func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) { + svc, fixtures := SetupTestService(t, WithSigningKey("test-key")) + + req := &ImageRequest{ + SourceHost: fixtures.OtherHost, + SourcePath: "/uploads/image.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + // Should fail validation - not whitelisted and no signature + err := svc.ValidateRequest(req) + if err == nil { + t.Error("ValidateRequest() expected error for non-whitelisted host without signature") + } +} + +func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) { + signingKey := "test-signing-key-12345" + svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) + ctx := context.Background() + + req := &ImageRequest{ + SourceHost: fixtures.OtherHost, + SourcePath: "/uploads/image.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + // Generate a valid signature + signer := NewSigner(signingKey) + req.Expires = time.Now().Add(time.Hour) + req.Signature = signer.Sign(req) + + // Should pass validation + err := svc.ValidateRequest(req) + if err != nil { + t.Errorf("ValidateRequest() error = %v", err) + } + + // Should fetch successfully + resp, err := svc.Get(ctx, req) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer resp.Content.Close() + + data, err := io.ReadAll(resp.Content) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + if len(data) == 0 { + t.Error("expected non-empty response") + } +} + +func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) { + signingKey := "test-signing-key-12345" + svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) + + req := &ImageRequest{ + SourceHost: fixtures.OtherHost, + SourcePath: "/uploads/image.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + // Generate an expired signature + signer := NewSigner(signingKey) + req.Expires = time.Now().Add(-time.Hour) // Already expired + req.Signature = signer.Sign(req) + + // Should fail validation + err := svc.ValidateRequest(req) + if err == nil { + t.Error("ValidateRequest() expected error for expired signature") + } +} + +func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) { + signingKey := "test-signing-key-12345" + svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) + + req := &ImageRequest{ + SourceHost: fixtures.OtherHost, + SourcePath: "/uploads/image.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + // Set an invalid signature + req.Signature = "invalid-signature" + req.Expires = time.Now().Add(time.Hour) + + // Should fail validation + err := svc.ValidateRequest(req) + if err == nil { + t.Error("ValidateRequest() expected error for invalid signature") + } +} + +func TestService_Get_InvalidFile(t *testing.T) { + svc, fixtures := SetupTestService(t) + ctx := context.Background() + + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: "/images/fake.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + // Should fail because magic bytes don't match + _, err := svc.Get(ctx, req) + if err == nil { + t.Error("Get() expected error for invalid file") + } +} + +func TestService_Get_NotFound(t *testing.T) { + svc, fixtures := SetupTestService(t) + ctx := context.Background() + + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: "/images/nonexistent.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + _, err := svc.Get(ctx, req) + if err == nil { + t.Error("Get() expected error for nonexistent file") + } +} + +func TestService_Get_FormatConversion(t *testing.T) { + svc, fixtures := SetupTestService(t) + ctx := context.Background() + + tests := []struct { + name string + sourcePath string + outFormat ImageFormat + wantMIME string + }{ + { + name: "JPEG to PNG", + sourcePath: "/images/photo.jpg", + outFormat: FormatPNG, + wantMIME: "image/png", + }, + { + name: "PNG to JPEG", + sourcePath: "/images/logo.png", + outFormat: FormatJPEG, + wantMIME: "image/jpeg", + }, + { + name: "GIF to PNG", + sourcePath: "/images/animation.gif", + outFormat: FormatPNG, + wantMIME: "image/png", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: tt.sourcePath, + Size: Size{Width: 50, Height: 50}, + Format: tt.outFormat, + Quality: 85, + FitMode: FitCover, + } + + resp, err := svc.Get(ctx, req) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer resp.Content.Close() + + if resp.ContentType != tt.wantMIME { + t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME) + } + + // Verify magic bytes match the expected format + data, err := io.ReadAll(resp.Content) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + detectedMIME, err := DetectFormat(data) + if err != nil { + t.Fatalf("failed to detect format: %v", err) + } + + expectedFormat, ok := MIMEToImageFormat(tt.wantMIME) + if !ok { + t.Fatalf("unknown format for MIME type: %s", tt.wantMIME) + } + + detectedFormat, ok := MIMEToImageFormat(string(detectedMIME)) + if !ok { + t.Fatalf("unknown format for detected MIME type: %s", detectedMIME) + } + + if detectedFormat != expectedFormat { + t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat) + } + }) + } +} + +func TestService_Get_Caching(t *testing.T) { + svc, fixtures := SetupTestService(t) + ctx := context.Background() + + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: "/images/photo.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + // First request - should be a cache miss + resp1, err := svc.Get(ctx, req) + if err != nil { + t.Fatalf("Get() first request error = %v", err) + } + + if resp1.CacheStatus != CacheMiss { + t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss) + } + + data1, err := io.ReadAll(resp1.Content) + if err != nil { + t.Fatalf("failed to read first response: %v", err) + } + resp1.Content.Close() + + // Second request - should be a cache hit + resp2, err := svc.Get(ctx, req) + if err != nil { + t.Fatalf("Get() second request error = %v", err) + } + + if resp2.CacheStatus != CacheHit { + t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit) + } + + data2, err := io.ReadAll(resp2.Content) + if err != nil { + t.Fatalf("failed to read second response: %v", err) + } + resp2.Content.Close() + + // Content should be identical + if len(data1) != len(data2) { + t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2)) + } +} + +func TestService_Get_DifferentSizes(t *testing.T) { + svc, fixtures := SetupTestService(t) + ctx := context.Background() + + // Request same image at different sizes + sizes := []Size{ + {Width: 25, Height: 25}, + {Width: 50, Height: 50}, + {Width: 75, Height: 75}, + } + + var responses [][]byte + + for _, size := range sizes { + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: "/images/photo.jpg", + Size: size, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + resp, err := svc.Get(ctx, req) + if err != nil { + t.Fatalf("Get() error for size %v = %v", size, err) + } + + data, err := io.ReadAll(resp.Content) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + resp.Content.Close() + + responses = append(responses, data) + } + + // All responses should be different sizes (different cache entries) + for i := 0; i < len(responses)-1; i++ { + if len(responses[i]) == len(responses[i+1]) { + // Not necessarily an error, but worth noting + t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i])) + } + } +} + +func TestService_ValidateRequest_NoSigningKey(t *testing.T) { + // Service with no signing key - all non-whitelisted requests should fail + svc, fixtures := SetupTestService(t, WithNoWhitelist()) + + req := &ImageRequest{ + SourceHost: fixtures.OtherHost, + SourcePath: "/uploads/image.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + err := svc.ValidateRequest(req) + if err == nil { + t.Error("ValidateRequest() expected error when no signing key and host not whitelisted") + } +} + +func TestService_Get_ContextCancellation(t *testing.T) { + svc, fixtures := SetupTestService(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + req := &ImageRequest{ + SourceHost: fixtures.GoodHost, + SourcePath: "/images/photo.jpg", + Size: Size{Width: 50, Height: 50}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + _, err := svc.Get(ctx, req) + if err == nil { + t.Error("Get() expected error for cancelled context") + } +} diff --git a/internal/imgcache/testutil_test.go b/internal/imgcache/testutil_test.go new file mode 100644 index 0000000..fc61a34 --- /dev/null +++ b/internal/imgcache/testutil_test.go @@ -0,0 +1,232 @@ +package imgcache + +import ( + "bytes" + "database/sql" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "io/fs" + "testing" + "testing/fstest" + "time" + + "sneak.berlin/go/pixa/internal/database" +) + +// TestFixtures contains paths to test files in the mock filesystem. +type TestFixtures struct { + // Valid image files + GoodHostJPEG string // whitelisted host, valid JPEG + GoodHostPNG string // whitelisted host, valid PNG + GoodHostGIF string // whitelisted host, valid GIF + OtherHostJPEG string // non-whitelisted host, valid JPEG + OtherHostPNG string // non-whitelisted host, valid PNG + + // Invalid/edge case files + InvalidFile string // file with wrong magic bytes + EmptyFile string // zero-byte file + TextFile string // text file masquerading as image + + // Hostnames + GoodHost string // whitelisted hostname + OtherHost string // non-whitelisted hostname +} + +// DefaultFixtures returns the standard test fixture paths. +func DefaultFixtures() *TestFixtures { + return &TestFixtures{ + GoodHostJPEG: "goodhost.example.com/images/photo.jpg", + GoodHostPNG: "goodhost.example.com/images/logo.png", + GoodHostGIF: "goodhost.example.com/images/animation.gif", + OtherHostJPEG: "otherhost.example.com/uploads/image.jpg", + OtherHostPNG: "otherhost.example.com/uploads/icon.png", + InvalidFile: "goodhost.example.com/images/fake.jpg", + EmptyFile: "goodhost.example.com/images/empty.jpg", + TextFile: "goodhost.example.com/images/text.png", + GoodHost: "goodhost.example.com", + OtherHost: "otherhost.example.com", + } +} + +// NewTestFS creates a mock filesystem with test images. +func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) { + t.Helper() + + fixtures := DefaultFixtures() + + // Generate test images + jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}) + pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255}) + gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255}) + + // Create the mock filesystem + mockFS := fstest.MapFS{ + // Good host files + fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData}, + fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData}, + fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData}, + + // Other host files + fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData}, + fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData}, + + // Invalid files + fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")}, + fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}}, + fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")}, + } + + return mockFS, fixtures +} + +// generateTestJPEG creates a minimal valid JPEG image. +func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, c) + } + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil { + t.Fatalf("failed to encode test JPEG: %v", err) + } + + return buf.Bytes() +} + +// generateTestPNG creates a minimal valid PNG image. +func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, c) + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("failed to encode test PNG: %v", err) + } + + return buf.Bytes() +} + +// generateTestGIF creates a minimal valid GIF image. +func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte { + t.Helper() + + img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White}) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.SetColorIndex(x, y, 0) + } + } + + var buf bytes.Buffer + if err := gif.Encode(&buf, img, nil); err != nil { + t.Fatalf("failed to encode test GIF: %v", err) + } + + return buf.Bytes() +} + +// SetupTestService creates a Service with mock fetcher for testing. +func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) { + t.Helper() + + mockFS, fixtures := NewTestFS(t) + + cfg := &testServiceConfig{ + whitelist: []string{fixtures.GoodHost}, + signingKey: "", + } + + for _, opt := range opts { + opt(cfg) + } + + // Create temp directory for cache + tmpDir := t.TempDir() + + // Create in-memory database + db := setupServiceTestDB(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) + } + + svc, err := NewService(&ServiceConfig{ + Cache: cache, + Fetcher: NewMockFetcher(mockFS), + SigningKey: cfg.signingKey, + Whitelist: cfg.whitelist, + }) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + return svc, fixtures +} + +// setupServiceTestDB creates an in-memory SQLite database for testing +// using the production schema. +func setupServiceTestDB(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) + } + + // Use the real production schema via migrations + if err := database.ApplyMigrations(db); err != nil { + t.Fatalf("failed to apply migrations: %v", err) + } + + return db +} + +type testServiceConfig struct { + whitelist []string + signingKey string +} + +// TestServiceOption configures the test service. +type TestServiceOption func(*testServiceConfig) + +// WithWhitelist sets the whitelist for the test service. +func WithWhitelist(hosts ...string) TestServiceOption { + return func(c *testServiceConfig) { + c.whitelist = hosts + } +} + +// WithSigningKey sets the signing key for the test service. +func WithSigningKey(key string) TestServiceOption { + return func(c *testServiceConfig) { + c.signingKey = key + } +} + +// WithNoWhitelist removes all whitelisted hosts. +func WithNoWhitelist() TestServiceOption { + return func(c *testServiceConfig) { + c.whitelist = nil + } +}