Failing tests, committed ahead of the fix. Stats totals are checked after storing a source image and two processed variants. A walk through Service.Get (a miss that fetches, a hit, a miss that reuses the cached source, a source failing the magic byte check, a source not found) checks every cache_stats counter after each step. The warn-log test for the Stats queries now drops source_content and variant_content, the tables Stats will read. Model: opus-5-5
312 lines
7.5 KiB
Go
312 lines
7.5 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"io/fs"
|
|
"log/slog"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/database"
|
|
)
|
|
|
|
func setupStatsTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db
|
|
}
|
|
|
|
func TestStats_HitRateIsRatio(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := setupStatsTestDB(t)
|
|
dir := t.TempDir()
|
|
|
|
cache, err := NewCache(db, CacheConfig{
|
|
StateDir: dir,
|
|
CacheTTL: time.Hour,
|
|
NegativeTTL: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set some hit/miss counts and a transform_count
|
|
_, err = db.ExecContext(ctx, `
|
|
UPDATE cache_stats
|
|
SET hit_count = 75, miss_count = 25, transform_count = 9999
|
|
WHERE id = 1
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stats, err := cache.Stats(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if stats.HitCount != 75 {
|
|
t.Errorf("HitCount = %d, want 75", stats.HitCount)
|
|
}
|
|
|
|
if stats.MissCount != 25 {
|
|
t.Errorf("MissCount = %d, want 25", stats.MissCount)
|
|
}
|
|
|
|
// HitRate should be 0.75, NOT 9999 (transform_count)
|
|
expectedRate := 0.75
|
|
if math.Abs(stats.HitRate-expectedRate) > 0.001 {
|
|
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)",
|
|
stats.HitRate, expectedRate)
|
|
}
|
|
}
|
|
|
|
func TestStats_ZeroCounts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := setupStatsTestDB(t)
|
|
dir := t.TempDir()
|
|
|
|
cache, err := NewCache(db, CacheConfig{
|
|
StateDir: dir,
|
|
CacheTTL: time.Hour,
|
|
NegativeTTL: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stats, err := cache.Stats(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// With zero hits and misses, HitRate should be 0, not some garbage value
|
|
if stats.HitRate != 0.0 {
|
|
t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate)
|
|
}
|
|
}
|
|
|
|
// TestStats_LogsFailedCountQueries verifies that a failed item count query
|
|
// and a failed size query are each logged at warn and Stats still succeeds.
|
|
func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := setupStatsTestDB(t)
|
|
|
|
var logBuf bytes.Buffer
|
|
|
|
cache, err := NewCache(db, CacheConfig{
|
|
StateDir: t.TempDir(),
|
|
CacheTTL: time.Hour,
|
|
NegativeTTL: 5 * time.Minute,
|
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(t.Context(),
|
|
`DROP TABLE source_content; DROP TABLE variant_content`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = cache.Stats(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Stats() error = %v, want nil", err)
|
|
}
|
|
|
|
for _, msg := range []string{
|
|
"failed to count cache items for stats",
|
|
"failed to sum cache size for stats",
|
|
} {
|
|
want := `"level":"WARN","msg":"` + msg + `"`
|
|
if !strings.Contains(logBuf.String(), want) {
|
|
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIncrementStats_LogsFailedUpdates verifies that a failed hit or miss
|
|
// count update and a failed upstream fetch count update are each logged at
|
|
// warn.
|
|
func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := setupStatsTestDB(t)
|
|
|
|
var logBuf bytes.Buffer
|
|
|
|
cache, err := NewCache(db, CacheConfig{
|
|
StateDir: t.TempDir(),
|
|
CacheTTL: time.Hour,
|
|
NegativeTTL: 5 * time.Minute,
|
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(t.Context(), `DROP TABLE cache_stats`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cache.IncrementStats(t.Context(), false, 1024)
|
|
|
|
for _, msg := range []string{
|
|
"failed to count cache hit or miss",
|
|
"failed to count upstream fetch",
|
|
} {
|
|
want := `"level":"WARN","msg":"` + msg + `"`
|
|
if !strings.Contains(logBuf.String(), want) {
|
|
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
|
|
// TotalSizeBytes cover the stored source images and processed variants.
|
|
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
|
|
storeEvictionTestSource(t, cache, testHostCDN, testPathCat,
|
|
bytes.Repeat([]byte{0xAA}, 1000))
|
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
|
bytes.Repeat([]byte{0xAB}, 500))
|
|
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
|
bytes.Repeat([]byte{0xAC}, 250))
|
|
|
|
stats, err := cache.Stats(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Stats() error = %v", err)
|
|
}
|
|
|
|
if stats.TotalItems != 3 {
|
|
t.Errorf("TotalItems = %d, want 3 (1 source, 2 variants)", stats.TotalItems)
|
|
}
|
|
|
|
if stats.TotalSizeBytes != 1750 {
|
|
t.Errorf("TotalSizeBytes = %d, want 1750 (1000+500+250)",
|
|
stats.TotalSizeBytes)
|
|
}
|
|
}
|
|
|
|
// cacheStatsCounters holds the counters of the cache_stats row, in column
|
|
// order.
|
|
type cacheStatsCounters struct {
|
|
hitCount int64
|
|
missCount int64
|
|
upstreamFetchCount int64
|
|
upstreamFetchBytes int64
|
|
transformCount int64
|
|
}
|
|
|
|
// readCacheStatsCounters reads the counters of the cache_stats row.
|
|
func readCacheStatsCounters(t *testing.T, cache *Cache) cacheStatsCounters {
|
|
t.Helper()
|
|
|
|
var got cacheStatsCounters
|
|
|
|
err := cache.db.QueryRowContext(t.Context(), `
|
|
SELECT hit_count, miss_count, upstream_fetch_count,
|
|
upstream_fetch_bytes, transform_count
|
|
FROM cache_stats WHERE id = 1
|
|
`).Scan(&got.hitCount, &got.missCount, &got.upstreamFetchCount,
|
|
&got.upstreamFetchBytes, &got.transformCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to read cache_stats: %v", err)
|
|
}
|
|
|
|
return got
|
|
}
|
|
|
|
// TestService_Get_CountsStats walks Get through a miss that fetches the
|
|
// source, a hit, a miss that reuses the cached source, and two misses whose
|
|
// source cannot be used, checking every cache_stats counter after each.
|
|
func TestService_Get_CountsStats(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
|
|
// NewTestFS builds the same files the test service's fetcher serves.
|
|
testFS, _ := NewTestFS(t)
|
|
|
|
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
|
|
|
|
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
|
steps := []struct {
|
|
name string
|
|
path string
|
|
size int
|
|
wantErr bool
|
|
want cacheStatsCounters
|
|
}{
|
|
{"miss that fetches the source", testPathPhoto, 50, false,
|
|
cacheStatsCounters{0, 1, 1, photoBytes, 1}},
|
|
{"hit", testPathPhoto, 50, false,
|
|
cacheStatsCounters{1, 1, 1, photoBytes, 1}},
|
|
{"miss that reuses the cached source", testPathPhoto, 25, false,
|
|
cacheStatsCounters{1, 2, 1, photoBytes, 2}},
|
|
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
|
|
cacheStatsCounters{1, 3, 2, photoBytes + fakeBytes, 2}},
|
|
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
|
|
cacheStatsCounters{1, 4, 2, photoBytes + fakeBytes, 2}},
|
|
}
|
|
|
|
for _, step := range steps {
|
|
resp, err := svc.Get(t.Context(), &ImageRequest{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: step.path,
|
|
Size: Size{Width: step.size, Height: step.size},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
})
|
|
if (err != nil) != step.wantErr {
|
|
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
|
|
}
|
|
|
|
if err == nil {
|
|
_ = resp.Content.Close()
|
|
}
|
|
|
|
got := readCacheStatsCounters(t, svc.cache)
|
|
if got != step.want {
|
|
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
|
}
|
|
}
|
|
}
|