package test import ( domain "RenewCMS/internal/domain/image" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/usage" "context" "strconv" "sync" "testing" "time" "gorm.io/gorm" ) func newUsageTracker(repo domain.VariantUsageRepository, clock *time.Time) *usage.Tracker { return usage.NewTracker(repo, func() time.Time { return *clock }) } func TestVariantUsageTracker(t *testing.T) { start := time.Date(2026, time.October, 8, 10, 0, 0, 0, time.UTC) t.Run("The flush writes one batch for many files", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := newUsageTracker(repo, &clock) for index := 0; index < 40; index++ { tracker.RecordHit("thumb", "file-"+strconv.Itoa(index)+".png") } if err := tracker.Flush(); err != nil { t.Fatal(err) } if repo.batchCount() != 1 || repo.callCount() != 1 || len(repo.recordedHits()) != 40 { t.Errorf("batches = %d, calls = %d, hits = %d", repo.batchCount(), repo.callCount(), len(repo.recordedHits())) } }) t.Run("Nothing pending means no write", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := newUsageTracker(repo, &clock) _ = tracker.Flush() tracker.RecordHit("thumb", "a.png") _ = tracker.Flush() _ = tracker.Flush() if repo.callCount() != 1 { t.Errorf("calls = %d", repo.callCount()) } }) t.Run("A thousand hits on one file are one update with the time of the last hit", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := newUsageTracker(repo, &clock) for index := 0; index < 1000; index++ { clock = start.Add(time.Duration(index) * time.Second) tracker.RecordHit("thumb", "a.png") } _ = tracker.Flush() hits := repo.recordedHits() if repo.callCount() != 1 || len(hits) != 1 || !hits[0].At.Equal(start.Add(999*time.Second)) { t.Errorf("calls = %d, hits = %+v", repo.callCount(), hits) } }) t.Run("A failed flush keeps the pending hits", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := newUsageTracker(repo, &clock) repo.fail = errUsageStoreDown tracker.RecordHit("thumb", "a.png") if err := tracker.Flush(); err == nil { t.Fatal("the failure was swallowed") } clock = start.Add(time.Minute) tracker.RecordHit("thumb", "a.png") tracker.RecordHit("hero", "b.png") repo.fail = nil _ = tracker.Flush() hits := repo.recordedHits() if repo.batchCount() != 1 || len(hits) != 2 { t.Fatalf("batches = %d, hits = %+v", repo.batchCount(), hits) } for _, hit := range hits { if !hit.At.Equal(clock) { t.Errorf("hit = %+v", hit) } } }) t.Run("A failed flush keeps the pending rejections", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := newUsageTracker(repo, &clock) repo.fail = errUsageStoreDown tracker.RecordRejection() tracker.RecordRejection() _ = tracker.Flush() tracker.RecordRejection() repo.fail = nil _ = tracker.Flush() _ = tracker.Flush() if repo.recordedRejections() != 3 || repo.batchCount() != 1 || repo.days["2026-10-08"] != 3 { t.Errorf("rejections = %d, batches = %d, days = %v", repo.recordedRejections(), repo.batchCount(), repo.days) } }) t.Run("The flush loop and the size trigger", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := usage.NewTrackerWith(repo, func() time.Time { return clock }, time.Hour, 500) ctx, stop := context.WithCancel(context.Background()) defer stop() done := make(chan struct{}) go func() { defer close(done) tracker.Run(ctx) }() for index := 0; index < 499; index++ { tracker.RecordHit("thumb", "file-"+strconv.Itoa(index)+".png") } time.Sleep(50 * time.Millisecond) if repo.callCount() != 0 { t.Fatal("flushed before the size trigger") } tracker.RecordHit("thumb", "file-499.png") waitUntilUsage(t, func() bool { return repo.callCount() == 1 }) tracker.RecordHit("thumb", "late.png") stop() select { case <-done: case <-time.After(3 * time.Second): t.Fatal("the loop did not stop") } if repo.callCount() != 2 { t.Errorf("calls after shutdown = %d", repo.callCount()) } }) t.Run("The defaults are a thirty second interval and a 500 key trigger", func(t *testing.T) { if usage.FlushInterval() != 30*time.Second || usage.FlushKeyLimit != 500 { t.Errorf("interval = %v, limit = %d", usage.FlushInterval(), usage.FlushKeyLimit) } }) t.Run("The same key repeated never reaches the size trigger", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := usage.NewTrackerWith(repo, func() time.Time { return clock }, time.Hour, 2) ctx, stop := context.WithCancel(context.Background()) defer stop() go tracker.Run(ctx) for index := 0; index < 5; index++ { tracker.RecordHit("thumb", "a.png") } time.Sleep(50 * time.Millisecond) if repo.callCount() != 0 { t.Errorf("calls = %d", repo.callCount()) } }) t.Run("A tick flushes", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := usage.NewTrackerWith(repo, func() time.Time { return clock }, 10*time.Millisecond, 500) ctx, stop := context.WithCancel(context.Background()) defer stop() go tracker.Run(ctx) tracker.RecordHit("thumb", "a.png") waitUntilUsage(t, func() bool { return repo.callCount() == 1 }) }) t.Run("Concurrent requests are race free", func(t *testing.T) { repo, clock := newMockVariantUsageRepo(), start tracker := newUsageTracker(repo, &clock) var group sync.WaitGroup for worker := 0; worker < 50; worker++ { group.Add(1) go func() { defer group.Done() for index := 0; index < 100; index++ { tracker.RecordHit("thumb", "file-"+strconv.Itoa(index)+".png") tracker.RecordRejection() } }() } flushed := make(chan struct{}) go func() { defer close(flushed) for index := 0; index < 20; index++ { _ = tracker.Flush() } }() group.Wait() <-flushed _ = tracker.Flush() if repo.recordedRejections() != 5000 { t.Errorf("rejections = %d", repo.recordedRejections()) } seen := map[string]bool{} for _, hit := range repo.recordedHits() { seen[hit.SourceName] = true } if len(seen) != 100 { t.Errorf("distinct files = %d", len(seen)) } }) } func waitUntilUsage(t *testing.T, condition func() bool) { t.Helper() deadline := time.Now().Add(500 * time.Millisecond) for time.Now().Before(deadline) { if condition() { return } time.Sleep(5 * time.Millisecond) } t.Fatal("the condition was not met in time") } func requestedAt(t *testing.T, db *gorm.DB, variantName string, sourceName string) *time.Time { t.Helper() var row entity.ImageVariantFile if err := db.Where("variant_name = ? AND source_name = ?", variantName, sourceName).First(&row).Error; err != nil { t.Fatal(err) } return row.LastRequestedAt } func TestVariantUsageRepository(t *testing.T) { noon := time.Date(2026, time.October, 8, 10, 5, 0, 0, time.UTC) seed := func(t *testing.T) (*gorm.DB, *persistence.VariantFileRepository, *persistence.VariantUsageRepository) { t.Helper() db := openActivityDatabase(t) files := persistence.NewVariantFileRepository(db) _, err := files.Save(domain.VariantFile{VariantName: "thumb", SourceName: "a.png", CachedName: "g1", ContentType: "image/png", Size: 10, Fingerprint: "f"}) if err != nil { t.Fatal(err) } return db, files, persistence.NewVariantUsageRepository(db) } t.Run("A batch sets the last requested time of the file", func(t *testing.T) { db, _, usageRepo := seed(t) err := usageRepo.Record(domain.VariantUsageBatch{Hits: []domain.VariantHit{{VariantName: "thumb", SourceName: "a.png", At: noon}}}) if got := requestedAt(t, db, "thumb", "a.png"); err != nil || got == nil || !got.Equal(noon) { t.Errorf("last_requested_at = %v, err = %v", got, err) } }) t.Run("A stored time never moves back", func(t *testing.T) { db, _, usageRepo := seed(t) _ = usageRepo.Record(domain.VariantUsageBatch{Hits: []domain.VariantHit{{VariantName: "thumb", SourceName: "a.png", At: noon}}}) _ = usageRepo.Record(domain.VariantUsageBatch{Hits: []domain.VariantHit{{VariantName: "thumb", SourceName: "a.png", At: noon.Add(-4 * time.Minute)}}}) if got := requestedAt(t, db, "thumb", "a.png"); got == nil || !got.Equal(noon) { t.Errorf("last_requested_at = %v", got) } }) t.Run("A purged file ignores a late update", func(t *testing.T) { db, files, usageRepo := seed(t) row, _, _ := files.Find("thumb", "a.png") _ = files.Delete(row.ID) err := usageRepo.Record(domain.VariantUsageBatch{Hits: []domain.VariantHit{{VariantName: "thumb", SourceName: "a.png", At: noon}}}) var count int64 db.Model(&entity.ImageVariantFile{}).Count(&count) if err != nil || count != 0 { t.Errorf("err = %v, rows = %d", err, count) } }) t.Run("A regenerated file keeps its last requested time", func(t *testing.T) { db, files, usageRepo := seed(t) _ = usageRepo.Record(domain.VariantUsageBatch{Hits: []domain.VariantHit{{VariantName: "thumb", SourceName: "a.png", At: noon}}}) _, _ = files.Save(domain.VariantFile{VariantName: "thumb", SourceName: "a.png", CachedName: "g2", ContentType: "image/png", Size: 20, Fingerprint: "f2"}) if got := requestedAt(t, db, "thumb", "a.png"); got == nil || !got.Equal(noon) { t.Errorf("last_requested_at = %v", got) } }) t.Run("Rejections add up per day and the window sums its days", func(t *testing.T) { _, _, usageRepo := seed(t) day := func(value string) time.Time { parsed, _ := time.Parse(domain.RejectionDayLayout, value) return parsed } for _, batch := range [][]domain.RejectionCount{ {{Day: day("2026-10-08"), Count: 3}, {Day: day("2026-10-02"), Count: 5}}, {{Day: day("2026-10-08"), Count: 4}, {Day: day("2026-10-01"), Count: 9}}, } { if err := usageRepo.Record(domain.VariantUsageBatch{Rejections: batch}); err != nil { t.Fatal(err) } } total, err := usageRepo.RejectedSince(domain.RejectionWindowStart(time.Date(2026, time.October, 8, 23, 59, 0, 0, time.UTC))) if err != nil || total != 12 { t.Errorf("total = %d, err = %v", total, err) } }) t.Run("Old day rows are pruned by the batch", func(t *testing.T) { db, _, usageRepo := seed(t) day := func(value string) time.Time { parsed, _ := time.Parse(domain.RejectionDayLayout, value) return parsed } _ = usageRepo.Record(domain.VariantUsageBatch{Rejections: []domain.RejectionCount{{Day: day("2026-09-30"), Count: 1}, {Day: day("2026-10-02"), Count: 2}}}) _ = usageRepo.Record(domain.VariantUsageBatch{Rejections: []domain.RejectionCount{{Day: day("2026-10-08"), Count: 1}}, PruneBefore: domain.RejectionPruneBefore(noon)}) var days []string db.Model(&entity.ImageVariantRejectionDay{}).Order("day").Pluck("day", &days) if len(days) != 2 || days[0] != "2026-10-02" || days[1] != "2026-10-08" { t.Errorf("days = %v", days) } }) } func TestVariantCacheRepository(t *testing.T) { now := time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC) cutoff := domain.UnusedCutoff(now) setup := func(t *testing.T) (*gorm.DB, *persistence.VariantFileRepository) { t.Helper() db := openActivityDatabase(t) return db, persistence.NewVariantFileRepository(db) } save := func(t *testing.T, db *gorm.DB, files *persistence.VariantFileRepository, variantName string, sourceName string, size int64, created time.Time, requested *time.Time) { t.Helper() row, err := files.Save(domain.VariantFile{VariantName: variantName, SourceName: sourceName, CachedName: variantName + sourceName, ContentType: "image/png", Size: size, Fingerprint: "f"}) if err != nil { t.Fatal(err) } db.Model(&entity.ImageVariantFile{}).Where("id = ?", row.ID).Updates(map[string]any{"created_at": created, "last_requested_at": requested}) } at := func(days int) *time.Time { value := now.AddDate(0, 0, -days) return &value } t.Run("The reference time is last_requested_at when set and created_at otherwise", func(t *testing.T) { db, files := setup(t) save(t, db, files, "hero", "recent.png", 10, now.AddDate(0, 0, -90), at(29)) save(t, db, files, "hero", "old.png", 20, now.AddDate(0, 0, -1), at(31)) save(t, db, files, "print", "disabled.png", 40, now.AddDate(0, 0, -90), at(90)) save(t, db, files, "card", "never.png", 80, now.AddDate(0, 0, -45), nil) save(t, db, files, "card", "fresh.png", 160, now.AddDate(0, 0, -2), nil) totals, err := files.UnusedTotals(domain.UnusedFilter{VariantNames: []string{"hero", "card"}, Cutoff: cutoff}) if err != nil || totals.Files != 2 || totals.Bytes != 100 { t.Errorf("totals = %+v, err = %v", totals, err) } }) t.Run("A row whose reference time is exactly the cutoff is in use", func(t *testing.T) { db, files := setup(t) save(t, db, files, "hero", "edge.png", 10, now.AddDate(0, 0, -90), &cutoff) totals, _ := files.UnusedTotals(domain.UnusedFilter{VariantNames: []string{"hero"}, Cutoff: cutoff}) if totals.Files != 0 { t.Errorf("totals = %+v", totals) } }) t.Run("No enabled variant means nothing is unused", func(t *testing.T) { db, files := setup(t) save(t, db, files, "hero", "old.png", 10, now.AddDate(0, 0, -90), nil) totals, err := files.UnusedTotals(domain.UnusedFilter{Cutoff: cutoff}) page, pageErr := files.ListPage(domain.VariantFileFilter{Unused: &domain.UnusedFilter{Cutoff: cutoff}}, 0, 10) if err != nil || pageErr != nil || totals.Files != 0 || len(page) != 0 { t.Errorf("totals = %+v, page = %v", totals, page) } }) t.Run("The top sources are ordered by bytes then name and capped", func(t *testing.T) { db, files := setup(t) save(t, db, files, "hero", "b.png", 100, now, nil) save(t, db, files, "card", "b.png", 100, now, nil) save(t, db, files, "hero", "a.png", 200, now, nil) save(t, db, files, "hero", "c.png", 900, now, nil) top, err := files.TopSources(3) if err != nil || len(top) != 3 || top[0].SourceName != "c.png" || top[1].SourceName != "a.png" || top[2].SourceName != "b.png" || top[2].Files != 2 || top[2].Bytes != 200 { t.Errorf("top = %+v, err = %v", top, err) } }) t.Run("A page continues after the given id and filters by variant, source and unused", func(t *testing.T) { db, files := setup(t) for index := 0; index < 5; index++ { save(t, db, files, "hero", "s"+strconv.Itoa(index)+".png", 1, now.AddDate(0, 0, -90), nil) } save(t, db, files, "card", "s0.png", 1, now, nil) first, _ := files.ListPage(domain.VariantFileFilter{VariantName: "hero"}, 0, 2) second, _ := files.ListPage(domain.VariantFileFilter{VariantName: "hero"}, first[1].ID, 10) bySource, _ := files.ListPage(domain.VariantFileFilter{SourceName: "s0.png"}, 0, 10) unused, _ := files.ListPage(domain.VariantFileFilter{Unused: &domain.UnusedFilter{VariantNames: []string{"hero", "card"}, Cutoff: cutoff}}, 0, 10) if len(first) != 2 || len(second) != 3 || second[0].ID <= first[1].ID || len(bySource) != 2 || len(unused) != 5 { t.Errorf("first = %d, second = %d, bySource = %d, unused = %d", len(first), len(second), len(bySource), len(unused)) } }) } func TestImageRepositoryListByStoredNames(t *testing.T) { repo := persistence.NewImageRepository(openActivityDatabase(t)) for _, name := range []string{"a.png", "b.png", "c.png"} { _, _ = repo.Create(domain.Image{Path: domain.PublicPath(name), Filename: name}) } found, err := repo.ListByStoredNames([]string{"c.png", "a.png", "missing.png"}) none, noneErr := repo.ListByStoredNames(nil) if err != nil || noneErr != nil || len(found) != 2 || found[0].Filename != "a.png" || found[1].Filename != "c.png" || len(none) != 0 { t.Errorf("found = %+v, err = %v, none = %v", found, err, none) } }