Files
RenewCMS/test/image_variants_usage_test.go
T

444 lines
15 KiB
Go

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)
}
}