package test import ( domain "RenewCMS/internal/domain/image" "errors" "net/http" "net/http/httptest" "strconv" "strings" "sync" "time" ) var errUsageStoreDown = errors.New("usage store down") type mockVariantUsageRepo struct { mutex sync.Mutex batches []domain.VariantUsageBatch days map[string]int fail error calls int } var _ domain.VariantUsageRepository = &mockVariantUsageRepo{} func newMockVariantUsageRepo() *mockVariantUsageRepo { return &mockVariantUsageRepo{days: map[string]int{}} } func (m *mockVariantUsageRepo) Record(batch domain.VariantUsageBatch) error { m.mutex.Lock() defer m.mutex.Unlock() m.calls++ if m.fail != nil { return m.fail } m.batches = append(m.batches, batch) for _, rejection := range batch.Rejections { m.days[rejection.Day.Format(domain.RejectionDayLayout)] += rejection.Count } if !batch.PruneBefore.IsZero() { for day := range m.days { if day < batch.PruneBefore.Format(domain.RejectionDayLayout) { delete(m.days, day) } } } return nil } func (m *mockVariantUsageRepo) RejectedSince(day time.Time) (int, error) { m.mutex.Lock() defer m.mutex.Unlock() total := 0 for stored, count := range m.days { if stored >= day.Format(domain.RejectionDayLayout) { total += count } } return total, nil } func (m *mockVariantUsageRepo) recordedHits() []domain.VariantHit { m.mutex.Lock() defer m.mutex.Unlock() hits := []domain.VariantHit{} for _, batch := range m.batches { hits = append(hits, batch.Hits...) } return hits } func (m *mockVariantUsageRepo) recordedRejections() int { m.mutex.Lock() defer m.mutex.Unlock() total := 0 for _, batch := range m.batches { for _, rejection := range batch.Rejections { total += rejection.Count } } return total } func (m *mockVariantUsageRepo) batchCount() int { m.mutex.Lock() defer m.mutex.Unlock() return len(m.batches) } func (m *mockVariantUsageRepo) callCount() int { m.mutex.Lock() defer m.mutex.Unlock() return m.calls } type cacheFixtureImage struct { id uint32 filename string files int bytes int64 } var cacheFixtureTopImages = []cacheFixtureImage{ {1, "north-wall.jpg", 6, 6082765}, {2, "lime-plaster.jpg", 6, 5138022}, {3, "kitchen-after.webp", 5, 4613734}, {4, "kitchen-before.webp", 5, 4089446}, {5, "floorboards.jpg", 5, 3774873}, {6, "IMG_4471.jpg", 4, 3250585}, {7, "stone-wall-detail.jpg", 4, 2936012}, {8, "bathroom-tiles.png", 4, 2726297}, {9, "roof-lantern.jpg", 3, 2411724}, {10, "chimney-stack.jpg", 3, 2202009}, } type cacheFixtureVariant struct { name string enabled bool files int bytes int64 } var cacheFixtureVariants = []cacheFixtureVariant{ {"hero", true, 244, 88080384}, {"card", true, 318, 60817408}, {"thumb", true, 412, 22020096}, {"print", false, 74, 22020096}, {"square", true, 140, 19922944}, {"og", true, 96, 9437184}, } const ( cacheFixtureUnusedHeroRows = 200 cacheFixtureUnusedHeroBytes = 40000000 cacheFixtureUnusedThumbRows = 112 cacheFixtureUnusedThumbSize = 2991616 ) func splitBytes(total int64, parts int) []int64 { sizes := make([]int64, parts) for index := range sizes { sizes[index] = total / int64(parts) if int64(index) < total%int64(parts) { sizes[index]++ } } return sizes } func (e *variantEnv) seedCacheRow(variantName string, sourceName string, size int64, reference time.Time) domain.VariantFile { cached := "fixture-" + variantName + "-" + sourceName + ".jpeg" e.storage.seed(cached, []byte("generated")) saved, _ := e.files.Save(domain.VariantFile{VariantName: variantName, SourceName: sourceName, CachedName: cached, ContentType: "image/jpeg", Size: size, Fingerprint: "fixture"}) e.files.setReference(saved.ID, reference) return saved } func (e *variantEnv) seedDefaultCache() { recent := e.now.AddDate(0, 0, -5) stale := e.now.AddDate(0, 0, -31) for _, variant := range cacheFixtureVariants { seeded := seedVariant(e.variants, variant.name, 400, 300, domain.FitCover, domain.FormatJPEG, 80) seeded.Enabled = variant.enabled e.variants.variants[variant.name] = seeded } topBytes := map[string]int64{} topFiles := map[string]int{} for _, img := range cacheFixtureTopImages { e.seedImage(img.id, img.filename) for index, size := range splitBytes(img.bytes, img.files) { variant := cacheFixtureVariants[index%len(cacheFixtureVariants)].name e.seedCacheRow(variant, img.filename, size, recent) topBytes[variant] += size topFiles[variant]++ } } for _, variant := range cacheFixtureVariants { fillers := variant.files - topFiles[variant.name] remaining := variant.bytes - topBytes[variant.name] sizes := e.fillerSizes(variant.name, fillers, remaining) for index, size := range sizes { reference := recent if e.isFixtureStale(variant.name, index) { reference = stale } e.seedCacheRow(variant.name, "filler-"+variant.name+"-"+strconv.Itoa(index), size, reference) } } } func (e *variantEnv) isFixtureStale(variantName string, index int) bool { return (variantName == "hero" && index < cacheFixtureUnusedHeroRows) || (variantName == "thumb" && index < cacheFixtureUnusedThumbRows) } func (e *variantEnv) fillerSizes(variantName string, fillers int, remaining int64) []int64 { switch variantName { case "hero": return append(splitBytes(cacheFixtureUnusedHeroBytes, cacheFixtureUnusedHeroRows), splitBytes(remaining-cacheFixtureUnusedHeroBytes, fillers-cacheFixtureUnusedHeroRows)...) case "thumb": return append(splitBytes(cacheFixtureUnusedThumbSize, cacheFixtureUnusedThumbRows), splitBytes(remaining-cacheFixtureUnusedThumbSize, fillers-cacheFixtureUnusedThumbRows)...) default: return splitBytes(remaining, fillers) } } func (e *variantEnv) seedRejectedDays(days map[string]int) { for day, count := range days { e.usage.days[day] = count } } func (e *variantEnv) cacheStats() (*httptest.ResponseRecorder, map[string]any) { e.t.Helper() recorder := e.sendAsAdmin(http.MethodGet, "/v1/image-variants/cache", "") if recorder.Code != http.StatusOK { return recorder, nil } return recorder, decodeBody(e.t, recorder) } func (e *variantEnv) purgeCache(body string) *httptest.ResponseRecorder { e.t.Helper() return e.send(http.MethodPost, "/v1/image-variants/cache/purge", body, variantOwnerId) } func (e *variantEnv) recentRejectedFixture() { e.seedRejectedDays(map[string]int{"2026-10-08": 30, "2026-10-05": 7, "2026-09-20": 100}) } func compactJSON(body *httptest.ResponseRecorder) string { return strings.TrimSpace(body.Body.String()) }