Files
RenewCMS/test/image_variants_support_test.go

486 lines
13 KiB
Go

package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"io"
"log"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
var errVariantStorageFailure = errors.New("variant storage failure")
var variantStorageExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
}
type mockVariantRepo struct {
mutex sync.Mutex
variants map[string]domain.Variant
failGet error
calls int
}
var _ domain.VariantRepository = &mockVariantRepo{}
func newMockVariantRepo() *mockVariantRepo {
return &mockVariantRepo{variants: map[string]domain.Variant{}}
}
func (m *mockVariantRepo) List() ([]domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
listed := make([]domain.Variant, 0, len(m.variants))
for _, variant := range m.variants {
listed = append(listed, variant)
}
sort.Slice(listed, func(i, j int) bool { return listed[i].Name < listed[j].Name })
return listed, nil
}
func (m *mockVariantRepo) Get(name string) (domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if m.failGet != nil {
return domain.Variant{}, m.failGet
}
variant, found := m.variants[name]
if !found {
return domain.Variant{}, domain.ErrVariantNotFound
}
return variant, nil
}
func (m *mockVariantRepo) Create(variant domain.Variant) (domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if _, found := m.variants[variant.Name]; found {
return domain.Variant{}, domain.ErrVariantExists
}
m.variants[variant.Name] = variant
return variant, nil
}
func (m *mockVariantRepo) Update(variant domain.Variant) (domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if _, found := m.variants[variant.Name]; !found {
return domain.Variant{}, domain.ErrVariantNotFound
}
m.variants[variant.Name] = variant
return variant, nil
}
func (m *mockVariantRepo) Delete(name string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if _, found := m.variants[name]; !found {
return domain.ErrVariantNotFound
}
delete(m.variants, name)
return nil
}
func (m *mockVariantRepo) stored(name string) domain.Variant {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.variants[name]
}
func (m *mockVariantRepo) count() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return len(m.variants)
}
type mockVariantFileRepo struct {
mutex sync.Mutex
rows map[uint32]domain.VariantFile
nextID uint32
deleted []uint32
saves int
calls int
totalsBySourceCalls int
references map[uint32]time.Time
failTotals error
failPage error
totalsCalls int
unusedCalls int
topCalls int
pageLimits []int
}
var _ domain.VariantFileRepository = &mockVariantFileRepo{}
var _ domain.VariantCacheRepository = &mockVariantFileRepo{}
func newMockVariantFileRepo() *mockVariantFileRepo {
return &mockVariantFileRepo{rows: map[uint32]domain.VariantFile{}, references: map[uint32]time.Time{}}
}
func (m *mockVariantFileRepo) setReference(id uint32, at time.Time) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.references[id] = at
}
func (m *mockVariantFileRepo) isUnused(row domain.VariantFile, filter domain.UnusedFilter) bool {
return slices.Contains(filter.VariantNames, row.VariantName) && m.references[row.ID].Before(filter.Cutoff)
}
func (m *mockVariantFileRepo) UnusedTotals(filter domain.UnusedFilter) (domain.VariantFileTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.unusedCalls++
var totals domain.VariantFileTotals
for _, row := range m.rows {
if m.isUnused(row, filter) {
totals.Files++
totals.Bytes += row.Size
}
}
return totals, nil
}
func (m *mockVariantFileRepo) TopSources(limit int) ([]domain.SourceTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.topCalls++
bySource := map[string]domain.SourceTotals{}
for _, row := range m.rows {
current := bySource[row.SourceName]
bySource[row.SourceName] = domain.SourceTotals{SourceName: row.SourceName, Files: current.Files + 1, Bytes: current.Bytes + row.Size}
}
sources := make([]domain.SourceTotals, 0, len(bySource))
for _, source := range bySource {
sources = append(sources, source)
}
sort.Slice(sources, func(i, j int) bool {
if sources[i].Bytes != sources[j].Bytes {
return sources[i].Bytes > sources[j].Bytes
}
return sources[i].SourceName < sources[j].SourceName
})
return sources[:min(limit, len(sources))], nil
}
func (m *mockVariantFileRepo) ListPage(filter domain.VariantFileFilter, afterID uint32, limit int) ([]domain.VariantFile, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.pageLimits = append(m.pageLimits, limit)
if m.failPage != nil {
return nil, m.failPage
}
page := []domain.VariantFile{}
for _, row := range m.rows {
if row.ID <= afterID || (filter.VariantName != "" && row.VariantName != filter.VariantName) || (filter.SourceName != "" && row.SourceName != filter.SourceName) {
continue
}
if filter.Unused != nil && !m.isUnused(row, *filter.Unused) {
continue
}
page = append(page, row)
}
sort.Slice(page, func(i, j int) bool { return page[i].ID < page[j].ID })
return page[:min(limit, len(page))], nil
}
func (m *mockVariantFileRepo) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
for _, row := range m.rows {
if row.VariantName == variantName && row.SourceName == sourceName {
return row, true, nil
}
}
return domain.VariantFile{}, false, nil
}
func (m *mockVariantFileRepo) Save(file domain.VariantFile) (domain.VariantFile, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.saves++
for id, row := range m.rows {
if row.VariantName == file.VariantName && row.SourceName == file.SourceName {
file.ID = id
m.rows[id] = file
return file, nil
}
}
m.nextID++
file.ID = m.nextID
m.rows[file.ID] = file
return file, nil
}
func (m *mockVariantFileRepo) ListByVariant(variantName string) ([]domain.VariantFile, error) {
return m.filtered(func(row domain.VariantFile) bool { return row.VariantName == variantName }), nil
}
func (m *mockVariantFileRepo) ListBySource(sourceName string) ([]domain.VariantFile, error) {
return m.filtered(func(row domain.VariantFile) bool { return row.SourceName == sourceName }), nil
}
func (m *mockVariantFileRepo) filtered(keep func(domain.VariantFile) bool) []domain.VariantFile {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
kept := []domain.VariantFile{}
for _, row := range m.rows {
if keep(row) {
kept = append(kept, row)
}
}
sort.Slice(kept, func(i, j int) bool { return kept[i].ID < kept[j].ID })
return kept
}
func (m *mockVariantFileRepo) Delete(id uint32) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.deleted = append(m.deleted, id)
delete(m.rows, id)
return nil
}
func (m *mockVariantFileRepo) Totals() (map[string]domain.VariantFileTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.totalsCalls++
if m.failTotals != nil {
return nil, m.failTotals
}
totals := map[string]domain.VariantFileTotals{}
for _, row := range m.rows {
current := totals[row.VariantName]
totals[row.VariantName] = domain.VariantFileTotals{Files: current.Files + 1, Bytes: current.Bytes + row.Size}
}
return totals, nil
}
func (m *mockVariantFileRepo) TotalsBySource() (map[string]domain.VariantFileTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.totalsBySourceCalls++
totals := map[string]domain.VariantFileTotals{}
for _, row := range m.rows {
current := totals[row.SourceName]
totals[row.SourceName] = domain.VariantFileTotals{Files: current.Files + 1, Bytes: current.Bytes + row.Size}
}
return totals, nil
}
func (m *mockVariantFileRepo) rowsOf(variantName string) []domain.VariantFile {
rows, _ := m.ListByVariant(variantName)
return rows
}
func (m *mockVariantFileRepo) rowCount() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return len(m.rows)
}
type mockVariantStorage struct {
mutex sync.Mutex
blobs map[string][]byte
puts int
opens int
openCounts map[string]int
nextName int
deleted []string
failDelete map[string]error
}
var _ domain.Storage = &mockVariantStorage{}
func newMockVariantStorage() *mockVariantStorage {
return &mockVariantStorage{blobs: map[string][]byte{}, failDelete: map[string]error{}, openCounts: map[string]int{}}
}
func (m *mockVariantStorage) Put(contentType string, content io.Reader) (string, error) {
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
m.mutex.Lock()
defer m.mutex.Unlock()
m.puts++
m.nextName++
name := "gen-" + strconv.Itoa(m.nextName) + variantStorageExtensions[contentType]
m.blobs[name] = data
return name, nil
}
func (m *mockVariantStorage) Open(storedName string) (domain.Blob, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.opens++
m.openCounts[storedName]++
data, found := m.blobs[storedName]
if !found || strings.Contains(storedName, "/") {
return domain.Blob{}, domain.ErrBlobNotFound
}
return domain.Blob{Content: io.NopCloser(bytes.NewReader(data)), Size: int64(len(data)), ModTime: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)}, nil
}
func (m *mockVariantStorage) Delete(storedName string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.deleted = append(m.deleted, storedName)
if err := m.failDelete[storedName]; err != nil {
return err
}
delete(m.blobs, storedName)
return nil
}
func (m *mockVariantStorage) seed(storedName string, content []byte) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.blobs[storedName] = content
}
func (m *mockVariantStorage) has(storedName string) bool {
m.mutex.Lock()
defer m.mutex.Unlock()
_, found := m.blobs[storedName]
return found
}
func (m *mockVariantStorage) putCount() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.puts
}
func (m *mockVariantStorage) deletedNames() []string {
m.mutex.Lock()
defer m.mutex.Unlock()
return append([]string(nil), m.deleted...)
}
type countingRenderer struct {
inner domain.VariantRenderer
calls atomic.Int32
failFirst bool
onRender func()
failFor func(settings domain.VariantSettings) error
}
var _ domain.VariantRenderer = &countingRenderer{}
func newCountingRenderer() *countingRenderer {
return &countingRenderer{inner: imaging.NewVariantRenderer()}
}
func newCountingRendererWithoutWebP() *countingRenderer {
return &countingRenderer{inner: imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG)}
}
func (c *countingRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
call := c.calls.Add(1)
if c.onRender != nil {
c.onRender()
}
if c.failFor != nil {
if err := c.failFor(settings); err != nil {
return domain.RenderedVariant{}, err
}
}
if c.failFirst && call == 1 {
return domain.RenderedVariant{}, errors.New("render failure")
}
return c.inner.Render(source, settings)
}
func (c *countingRenderer) DeclaredSize(source io.Reader) (int, int, error) {
return c.inner.DeclaredSize(source)
}
func defaultServeLimits(maxSourceBytes int64) useCases.ServeVariantLimits {
return useCases.ServeVariantLimits{MaxSourceBytes: maxSourceBytes, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: variantDefaultRenders}
}
func (c *countingRenderer) EncodableFormats() []domain.VariantFormat {
return c.inner.EncodableFormats()
}
func (c *countingRenderer) renderCount() int {
return int(c.calls.Load())
}
func seedVariant(repo *mockVariantRepo, name string, width int, height int, fit domain.VariantFit, format domain.VariantFormat, quality int) domain.Variant {
settings := domain.VariantSettings{Fit: fit, Format: format}
if width > 0 {
settings.Width = &width
}
if height > 0 {
settings.Height = &height
}
if format != domain.FormatPNG {
settings.Quality = &quality
}
stamp := time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC)
variant, err := domain.NewVariant(name, settings, false, true, stamp)
if err != nil {
panic(err)
}
repo.variants[name] = variant
return variant
}
func seedVariantFiles(files *mockVariantFileRepo, storage *mockVariantStorage, variantName string, sourceName string, count int, size int64) []domain.VariantFile {
seeded := []domain.VariantFile{}
for index := 0; index < count; index++ {
cached := "seed-" + variantName + "-" + sourceName + "-" + strconv.Itoa(index) + ".jpeg"
storage.seed(cached, []byte("generated"))
saved, _ := files.Save(domain.VariantFile{
VariantName: variantName,
SourceName: sourceName + "-" + strconv.Itoa(index),
CachedName: cached,
ContentType: "image/jpeg",
Size: size,
Fingerprint: "seeded",
})
seeded = append(seeded, saved)
}
return seeded
}
func newInertServeVariantUseCase(storage domain.Storage) *useCases.ServeVariantUseCase {
return useCases.NewServeVariantUseCase(newMockVariantRepo(), newMockVariantFileRepo(), newMockImageRepo(), storage, newCountingRenderer(), defaultServeLimits(variantDefaultCap))
}
func newInertGenerateOnUploadUseCase() *useCases.GenerateVariantsOnUploadUseCase {
return useCases.NewGenerateVariantsOnUploadUseCase(newMockVariantRepo(), newInertServeVariantUseCase(newMockVariantStorage()), log.New(io.Discard, "", 0))
}
func (m *mockVariantStorage) openCountOf(storedName string) int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.openCounts[storedName]
}