444 lines
15 KiB
Go
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)
|
|
}
|
|
}
|