Files
RenewCMS/test/image_variants_cache_http_test.go

1005 lines
35 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
domain "RenewCMS/internal/domain/image"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
const (
cacheStatsPath = "/v1/image-variants/cache"
cachePurgePath = "/v1/image-variants/cache/purge"
)
func TestImageVariantsCacheGuards(t *testing.T) {
rows := []struct {
name string
method string
path string
credentials string
status int
body string
}{
{"no cookie", http.MethodGet, cacheStatsPath, "none", 401, apperr.UnauthorizedErrorMessage},
{"a stale token_version cookie", http.MethodGet, cacheStatsPath, "stale", 401, apperr.UnauthorizedErrorMessage},
{"the cookie of an unverified user", http.MethodGet, cacheStatsPath, "unverified", 403, apperr.VerificationRequiredErrorMessage},
{"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{"a valid API key", http.MethodGet, cacheStatsPath, "apikey", 401, apperr.UnauthorizedErrorMessage},
{"a valid API key on purge", http.MethodPost, cachePurgePath, "apikey", 401, apperr.UnauthorizedErrorMessage},
}
for _, row := range rows {
t.Run("Every cache route is guarded: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
seedVariantFiles(env.files, env.storage, "hero", "x", 2, 10)
callsBefore, variantCallsBefore := env.files.calls, env.variants.calls
request := httptest.NewRequest(row.method, row.path, strings.NewReader(`{"scope": "all"}`))
switch row.credentials {
case "stale":
request.AddCookie(env.cookie(variantStaleId, 0))
case "unverified":
request.AddCookie(env.cookie(variantUnverifiedId, 0))
case "plain":
request.AddCookie(env.cookie(variantPlainUserId, 0))
case "apikey":
request.Header = bearerKeyHeader()
}
recorder := httptest.NewRecorder()
env.admin.ServeHTTP(recorder, request)
assertStatusAndBody(t, recorder, row.status, row.body)
if env.files.calls != callsBefore || env.variants.calls != variantCallsBefore || env.files.rowCount() != 2 || len(env.storage.deletedNames()) != 0 || env.usage.callCount() != 0 || env.events() != 0 {
t.Error("an index row, blob, counter or activity event was touched")
}
})
}
for name, userId := range map[string]uint32{"the owner": variantOwnerId, "an admin": variantAdminId} {
t.Run("Owners and admins are both allowed: "+name, func(t *testing.T) {
env := newVariantEnv(t)
recorder := env.send(http.MethodGet, cacheStatsPath, "", userId)
if recorder.Code != http.StatusOK {
t.Errorf("status = %d", recorder.Code)
}
})
}
}
func assertNumber(t *testing.T, body map[string]any, key string, want float64) {
t.Helper()
if body[key] != want {
t.Errorf("%s = %v, want %v", key, body[key], want)
}
}
func TestImageVariantsCacheStatistics(t *testing.T) {
t.Run("The Default fixture", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.seedRejectedDays(map[string]int{"2026-10-08": 30, "2026-10-05": 7, "2026-09-20": 100})
recorder, body := env.cacheStats()
if recorder.Code != 200 {
t.Fatalf("status = %d %s", recorder.Code, recorder.Body.String())
}
assertNumber(t, body, "generated_files", 1284)
assertNumber(t, body, "disk_used", 222298112)
assertNumber(t, body, "variants_defined", 6)
assertNumber(t, body, "variants_enabled", 5)
assertNumber(t, body, "variants_disabled", 1)
assertNumber(t, body, "rejected_requests", 37)
assertNumber(t, body, "rejected_window_days", 7)
if got := marshalled(t, body["unused"]); got != `{"bytes":42991616,"days":30,"files":312}` {
t.Errorf("unused = %s", got)
}
byVariant := body["by_variant"].([]any)
wantNames := []string{"hero", "card", "print", "thumb", "square", "og"}
if len(byVariant) != len(wantNames) {
t.Fatalf("by_variant = %v", byVariant)
}
for index, name := range wantNames {
entry := byVariant[index].(map[string]any)
if entry["name"] != name || entry["enabled"] != (name != "print") {
t.Errorf("by_variant[%d] = %v", index, entry)
}
}
if got := marshalled(t, byVariant[0]); got != `{"bytes":88080384,"enabled":true,"files":244,"name":"hero"}` {
t.Errorf("hero = %s", got)
}
top := body["top_images"].([]any)
if len(top) != 10 {
t.Fatalf("top_images = %d", len(top))
}
for index, img := range cacheFixtureTopImages {
entry := top[index].(map[string]any)
if entry["id"] != float64(img.id) || entry["filename"] != img.filename || entry["url"] != "/static/uploadedImages/"+img.filename || entry["files"] != float64(img.files) || entry["bytes"] != float64(img.bytes) {
t.Errorf("top_images[%d] = %v", index, entry)
}
}
})
t.Run("Counts are the sum of the index rows", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
_, body := env.cacheStats()
files, bytes := 0.0, 0.0
for _, entry := range body["by_variant"].([]any) {
files += entry.(map[string]any)["files"].(float64)
bytes += entry.(map[string]any)["bytes"].(float64)
}
if body["generated_files"] != files || body["disk_used"] != bytes {
t.Errorf("totals = %v %v, sums = %v %v", body["generated_files"], body["disk_used"], files, bytes)
}
})
t.Run("A variant without a file is listed with zero", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
seedVariant(env.variants, "banner", 900, 0, domain.FitCover, domain.FormatJPEG, 80)
_, body := env.cacheStats()
assertNumber(t, body, "variants_defined", 7)
found := false
for _, entry := range body["by_variant"].([]any) {
if marshalled(t, entry) == `{"bytes":0,"enabled":true,"files":0,"name":"banner"}` {
found = true
}
}
if !found {
t.Errorf("by_variant = %v", body["by_variant"])
}
})
t.Run("A disabled variant keeps its files in the totals", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
_, body := env.cacheStats()
print := body["by_variant"].([]any)[2].(map[string]any)
if print["name"] != "print" || print["enabled"] != false || print["files"] != 74.0 || print["bytes"] != 22020096.0 {
t.Errorf("print = %v", print)
}
assertNumber(t, body, "generated_files", 1284)
assertNumber(t, body, "disk_used", 222298112)
if got := marshalled(t, body["unused"]); got != `{"bytes":42991616,"days":30,"files":312}` {
t.Errorf("unused = %s", got)
}
})
t.Run("An image deleted behind the index is not a top image", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.seedCacheRow("hero", "orphan-giant.jpg", 50000000, env.now)
_, body := env.cacheStats()
assertNumber(t, body, "generated_files", 1285)
for _, entry := range body["top_images"].([]any) {
if entry.(map[string]any)["url"] == "/static/uploadedImages/orphan-giant.jpg" {
t.Error("the orphan is listed")
}
}
if len(body["top_images"].([]any)) != 10 {
t.Errorf("top_images = %d", len(body["top_images"].([]any)))
}
})
t.Run("Top images are capped at ten and tie-break by id", func(t *testing.T) {
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
sizes := map[uint32]int64{1: 1100, 2: 1000, 3: 900, 4: 800, 5: 700, 6: 600, 7: 500, 8: 500, 9: 400, 10: 300, 11: 200}
names := map[uint32]string{7: "z7.png", 8: "a8.png"}
for id, size := range sizes {
name := names[id]
if name == "" {
name = "m" + string(rune('a'+id)) + ".png"
}
env.seedImage(id, name)
env.seedCacheRow("hero", name, size, env.now)
}
_, body := env.cacheStats()
top := body["top_images"].([]any)
if len(top) != 10 || top[6].(map[string]any)["id"] != 7.0 || top[7].(map[string]any)["id"] != 8.0 {
t.Errorf("top_images = %v", top)
}
})
seedTiedTop := func(env *variantEnv, tied map[uint32]string) {
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
for id := uint32(1); id <= 9; id++ {
name := "m" + string(rune('a'+id)) + ".png"
env.seedImage(id, name)
env.seedCacheRow("hero", name, int64(2000-100*id), env.now)
}
for id, name := range tied {
env.seedImage(id, name)
env.seedCacheRow("hero", name, 300, env.now)
}
}
t.Run("Exactly eleven sources tied at the cut are fetched again", func(t *testing.T) {
env := newVariantEnv(t)
seedTiedTop(env, map[uint32]string{10: "b10.png", 11: "a11.png"})
_, body := env.cacheStats()
top := body["top_images"].([]any)
if len(top) != 10 || top[9].(map[string]any)["id"] != 10.0 || env.files.topCalls != 2 {
t.Errorf("top_images = %v, fetches = %d", top, env.files.topCalls)
}
})
t.Run("A tie across the cut widens the fetch so the lower id wins", func(t *testing.T) {
env := newVariantEnv(t)
seedTiedTop(env, map[uint32]string{10: "c10.png", 11: "b11.png", 12: "a12.png"})
_, body := env.cacheStats()
top := body["top_images"].([]any)
if len(top) != 10 || top[9].(map[string]any)["id"] != 10.0 || env.files.topCalls != 2 {
t.Errorf("top_images = %v, fetches = %d", top, env.files.topCalls)
}
})
t.Run("An empty cache", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
for _, row := range env.files.rowsOf("hero") {
_ = env.files.Delete(row.ID)
}
for _, name := range []string{"card", "thumb", "print", "square", "og"} {
for _, row := range env.files.rowsOf(name) {
_ = env.files.Delete(row.ID)
}
}
env.seedRejectedDays(map[string]int{"2026-10-08": 37})
recorder, body := env.cacheStats()
assertNumber(t, body, "generated_files", 0)
assertNumber(t, body, "disk_used", 0)
assertNumber(t, body, "variants_defined", 6)
assertNumber(t, body, "rejected_requests", 37)
if got := marshalled(t, body["unused"]); got != `{"bytes":0,"days":30,"files":0}` {
t.Errorf("unused = %s", got)
}
if !strings.Contains(recorder.Body.String(), `"top_images":[]`) {
t.Errorf("body = %s", recorder.Body.String())
}
for _, entry := range body["by_variant"].([]any) {
if entry.(map[string]any)["files"] != 0.0 {
t.Errorf("entry = %v", entry)
}
}
})
t.Run("No variant defined", func(t *testing.T) {
env := newVariantEnv(t)
recorder, body := env.cacheStats()
if !strings.Contains(recorder.Body.String(), `"by_variant":[]`) {
t.Errorf("body = %s", recorder.Body.String())
}
assertNumber(t, body, "variants_defined", 0)
})
t.Run("Unused counts only enabled variants older than the cutoff", func(t *testing.T) {
env := newVariantEnv(t)
for _, name := range []string{"hero", "card"} {
seedVariant(env.variants, name, 100, 0, domain.FitCover, domain.FormatJPEG, 80)
}
print := seedVariant(env.variants, "print", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
print.Enabled = false
env.variants.variants["print"] = print
env.seedCacheRow("hero", "recent.jpg", 10, env.now.AddDate(0, 0, -29))
env.seedCacheRow("hero", "old.jpg", 20, env.now.AddDate(0, 0, -31))
env.seedCacheRow("print", "disabled.jpg", 40, env.now.AddDate(0, 0, -90))
env.seedCacheRow("card", "never-requested.jpg", 80, env.now.AddDate(0, 0, -45))
_, body := env.cacheStats()
if got := marshalled(t, body["unused"]); got != `{"bytes":100,"days":30,"files":2}` {
t.Errorf("unused = %s", got)
}
})
t.Run("A row requested exactly 30 days ago is still in use", func(t *testing.T) {
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
env.seedCacheRow("hero", "edge.jpg", 10, env.now.AddDate(0, 0, -30))
_, body := env.cacheStats()
if got := marshalled(t, body["unused"]); got != `{"bytes":0,"days":30,"files":0}` {
t.Errorf("unused = %s", got)
}
})
t.Run("The statistics are aggregated, not read per image", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.cacheStats()
if env.files.totalsCalls != 1 || env.files.unusedCalls != 1 || env.files.topCalls != 1 || env.images.storedNameLookups != 1 {
t.Errorf("calls = %d totals, %d unused, %d top, %d lookups", env.files.totalsCalls, env.files.unusedCalls, env.files.topCalls, env.images.storedNameLookups)
}
})
t.Run("The read flushes pending tracking first", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.seedRejectedDays(map[string]int{"2026-10-08": 37})
for index := 0; index < 12; index++ {
env.tracker.RecordHit("hero", "pending-"+string(rune('a'+index)))
}
for index := 0; index < 3; index++ {
env.tracker.RecordRejection()
}
_, body := env.cacheStats()
if env.usage.batchCount() != 1 || len(env.usage.recordedHits()) != 12 {
t.Errorf("batches = %d, hits = %d", env.usage.batchCount(), len(env.usage.recordedHits()))
}
assertNumber(t, body, "rejected_requests", 40)
})
t.Run("The statistics change nothing else", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
rowsBefore := env.files.rowCount()
env.cacheStats()
if env.storage.opens != 0 || env.renderer.renderCount() != 0 || env.files.rowCount() != rowsBefore || len(env.storage.deletedNames()) != 0 || env.events() != 0 {
t.Error("a blob was opened, a render happened, a row was deleted or an event was recorded")
}
})
t.Run("A failing repository answers 500", func(t *testing.T) {
env := newVariantEnv(t)
env.files.failTotals = errors.New("totals down")
recorder, _ := env.cacheStats()
assertStatusAndBody(t, recorder, 500, "Internal server error")
})
t.Run("The window is today and the six previous UTC days", func(t *testing.T) {
env := newVariantEnv(t)
env.now = time.Date(2026, time.October, 8, 23, 59, 0, 0, time.UTC)
env.seedRejectedDays(map[string]int{"2026-10-02": 5, "2026-10-01": 9})
_, body := env.cacheStats()
assertNumber(t, body, "rejected_requests", 5)
})
}
func TestImageVariantsCacheTracking(t *testing.T) {
trackedEnv := func(t *testing.T) *variantEnv {
t.Helper()
env := newVariantEnv(t)
env.seedThumb()
env.seedPhoto()
return env
}
thumbETag := func(env *variantEnv) string {
return domain.VariantETag(env.variants.stored("thumb").Settings(), variantSourceName)
}
hitsAfterFlush := func(env *variantEnv) []domain.VariantHit {
_ = env.tracker.Flush()
return env.usage.recordedHits()
}
t.Run("What counts as a request for a file", func(t *testing.T) {
t.Run("the public client gets the file cached", func(t *testing.T) {
env := trackedEnv(t)
env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
_ = env.tracker.Flush()
env.now = env.now.Add(time.Minute)
recorder := env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
hits := hitsAfterFlush(env)
if recorder.Code != 200 || len(hits) != 2 || hits[1].VariantName != "thumb" || hits[1].SourceName != variantSourceName || !hits[1].At.Equal(env.now) {
t.Errorf("status = %d, hits = %+v", recorder.Code, hits)
}
})
t.Run("the public client gets it and the file is generated by that request", func(t *testing.T) {
env := trackedEnv(t)
recorder := env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
hits := hitsAfterFlush(env)
if recorder.Code != 200 || env.storage.putCount() != 1 || len(hits) != 1 {
t.Errorf("status = %d, puts = %d, hits = %+v", recorder.Code, env.storage.putCount(), hits)
}
})
t.Run("the public client gets it with a matching If-None-Match and receives 304", func(t *testing.T) {
env := trackedEnv(t)
recorder := env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", http.Header{"If-None-Match": []string{thumbETag(env)}})
hits := hitsAfterFlush(env)
if recorder.Code != 304 || len(hits) != 1 {
t.Errorf("status = %d, hits = %+v", recorder.Code, hits)
}
})
unrecorded := map[string]string{
"the public client gets it with no variant": "/static/uploadedImages/" + variantSourceName,
"the public client gets it with ?v=unknown": "/static/uploadedImages/" + variantSourceName + "?v=unknown",
"the public client gets it with a disabled variant": "/static/uploadedImages/" + variantSourceName + "?v=print",
"the public client gets a missing file": "/static/uploadedImages/missing.png?v=thumb",
}
for name, url := range unrecorded {
t.Run(name, func(t *testing.T) {
env := trackedEnv(t)
print := seedVariant(env.variants, "print", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
print.Enabled = false
env.variants.variants["print"] = print
env.get(url, nil)
hits := hitsAfterFlush(env)
if len(hits) != 0 {
t.Errorf("hits = %+v", hits)
}
})
}
t.Run("an admin sends POST /v1/media/1/variants/thumb", func(t *testing.T) {
env := trackedEnv(t)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
hits := hitsAfterFlush(env)
if recorder.Code != 200 || len(hits) != 0 {
t.Errorf("status = %d, hits = %+v", recorder.Code, hits)
}
})
t.Run("an admin sends GET /v1/image-variants/cache", func(t *testing.T) {
env := trackedEnv(t)
env.cacheStats()
if len(env.usage.recordedHits()) != 0 {
t.Errorf("hits = %+v", env.usage.recordedHits())
}
})
})
t.Run("A thousand hits on one file are one pending key", func(t *testing.T) {
env := trackedEnv(t)
env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
_ = env.tracker.Flush()
callsBefore := env.usage.callCount()
base := env.now
for index := 1; index <= 1000; index++ {
env.now = base.Add(time.Duration(index) * time.Second)
env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
}
writesDuring := env.usage.callCount() - callsBefore
_ = env.tracker.Flush()
hits := env.usage.recordedHits()
if writesDuring != 0 || env.usage.batchCount() != 2 || len(hits) != 2 || !hits[1].At.Equal(base.Add(1000*time.Second)) {
t.Errorf("writes = %d, batches = %d, hits = %+v", writesDuring, env.usage.batchCount(), hits)
}
})
t.Run("A request is never blocked by or failed by the tracker", func(t *testing.T) {
env := trackedEnv(t)
env.usage.fail = errUsageStoreDown
env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
recorder := env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
if recorder.Code != 200 || recorder.Body.Len() == 0 {
t.Errorf("status = %d, body = %d bytes", recorder.Code, recorder.Body.Len())
}
})
}
func TestImageVariantsRejectionCounting(t *testing.T) {
rows := []struct {
url string
status int
counts int
}{
{"/static/uploadedImages/" + variantSourceName + "?v=unknown", 400, 1},
{"/static/uploadedImages/" + variantSourceName + "?v=print", 400, 1},
{"/static/uploadedImages/" + variantSourceName + "?width=400", 400, 1},
{"/static/uploadedImages/" + variantSourceName + "?v=Thumb", 400, 1},
{"/static/uploadedImages/missing.png?v=thumb", 404, 0},
{"/static/uploadedImages/" + variantSourceName + "?v=thumb", 200, 0},
{"/static/uploadedImages/" + variantSourceName, 200, 0},
}
setup := func(t *testing.T) *variantEnv {
t.Helper()
env := newVariantEnv(t)
env.seedThumb()
env.seedPhoto()
print := seedVariant(env.variants, "print", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
print.Enabled = false
env.variants.variants["print"] = print
return env
}
for _, row := range rows {
t.Run("What counts as rejected: "+row.url, func(t *testing.T) {
env := setup(t)
recorder := env.get(row.url, nil)
_ = env.tracker.Flush()
if recorder.Code != row.status || env.usage.recordedRejections() != row.counts {
t.Errorf("status = %d, rejections = %d", recorder.Code, env.usage.recordedRejections())
}
})
}
t.Run("A rejection is an in-memory increment", func(t *testing.T) {
env := setup(t)
for index := 0; index < 1000; index++ {
env.get("/static/uploadedImages/"+variantSourceName+"?v=unknown", nil)
}
writesDuring := env.usage.callCount()
_ = env.tracker.Flush()
batch := env.usage.batches[0]
if writesDuring != 0 || len(batch.Rejections) != 1 || batch.Rejections[0].Count != 1000 || batch.Rejections[0].Day.Format(domain.RejectionDayLayout) != "2026-10-08" {
t.Errorf("writes = %d, batch = %+v", writesDuring, batch)
}
})
t.Run("A day boundary starts a new bucket", func(t *testing.T) {
env := setup(t)
env.now = time.Date(2026, time.October, 8, 23, 59, 59, 0, time.UTC)
env.get("/static/uploadedImages/"+variantSourceName+"?v=unknown", nil)
env.now = time.Date(2026, time.October, 9, 0, 0, 0, 0, time.UTC)
env.get("/static/uploadedImages/"+variantSourceName+"?v=unknown", nil)
_ = env.tracker.Flush()
if len(env.usage.batches[0].Rejections) != 2 || env.usage.days["2026-10-08"] != 1 || env.usage.days["2026-10-09"] != 1 {
t.Errorf("days = %v", env.usage.days)
}
})
t.Run("Old day rows are pruned by the flush", func(t *testing.T) {
env := setup(t)
env.seedRejectedDays(map[string]int{"2026-09-30": 4, "2026-10-02": 5, "2026-10-08": 1})
env.get("/static/uploadedImages/"+variantSourceName+"?v=unknown", nil)
_ = env.tracker.Flush()
if _, found := env.usage.days["2026-09-30"]; found || env.usage.days["2026-10-02"] != 5 || env.usage.days["2026-10-08"] != 2 {
t.Errorf("days = %v", env.usage.days)
}
})
}
func TestImageVariantsCachePurge(t *testing.T) {
t.Run("Purge all removes every generated file of every variant", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(`{"scope": "all"}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"all","purged_files":1284,"purged_bytes":222298112,"failed_files":0}`)
if env.files.rowCount() != 0 || len(env.storage.deletedNames()) != 1284 || env.variants.count() != 6 {
t.Errorf("rows = %d, deleted = %d, variants = %d", env.files.rowCount(), len(env.storage.deletedNames()), env.variants.count())
}
})
t.Run("Purge all includes the files of disabled variants", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
printBlobs := []string{}
for _, row := range env.files.rowsOf("print") {
printBlobs = append(printBlobs, row.CachedName)
}
env.purgeCache(`{"scope": "all"}`)
deleted := env.storage.deletedNames()
for _, blob := range printBlobs {
if !containsString(deleted, blob) {
t.Fatalf("%s was not deleted", blob)
}
}
if len(printBlobs) != 74 {
t.Errorf("print blobs = %d", len(printBlobs))
}
})
t.Run("Purge unused removes the unused files of enabled variants only", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(`{"scope": "unused"}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"unused","purged_files":312,"purged_bytes":42991616,"failed_files":0}`)
if env.files.rowCount() != 972 || len(env.files.rowsOf("print")) != 74 {
t.Errorf("rows = %d, print = %d", env.files.rowCount(), len(env.files.rowsOf("print")))
}
})
t.Run("Purge by variant removes the files of that variant only", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(`{"scope": "variant", "variant": "hero"}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"variant","purged_files":244,"purged_bytes":88080384,"failed_files":0}`)
if env.files.rowCount() != 1040 || len(env.files.rowsOf("hero")) != 0 {
t.Errorf("rows = %d", env.files.rowCount())
}
})
t.Run("Purge by variant works for a disabled variant", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(`{"scope": "variant", "variant": "print"}`)
if decodeBody(t, recorder)["purged_files"] != 74.0 {
t.Errorf("body = %s", recorder.Body.String())
}
})
t.Run("Purge by image removes every variant file of that image", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(`{"scope": "image", "image_id": 1}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"image","purged_files":6,"purged_bytes":6082765,"failed_files":0}`)
remaining, _ := env.files.ListBySource("lime-plaster.jpg")
if len(remaining) != 6 || env.files.rowCount() != 1278 {
t.Errorf("lime-plaster rows = %d, rows = %d", len(remaining), env.files.rowCount())
}
})
t.Run("The next public request generates a purged file again", func(t *testing.T) {
env := newVariantEnv(t)
env.seedThumb()
env.seedPhoto()
env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
env.purgeCache(`{"scope": "all"}`)
recorder := env.get("/static/uploadedImages/"+variantSourceName+"?v=thumb", nil)
if recorder.Code != 200 || env.storage.putCount() != 2 {
t.Errorf("status = %d, puts = %d", recorder.Code, env.storage.putCount())
}
})
t.Run("Purging an empty cache answers zero", func(t *testing.T) {
env := newVariantEnv(t)
recorder := env.purgeCache(`{"scope": "all"}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"all","purged_files":0,"purged_bytes":0,"failed_files":0}`)
})
t.Run("A blob already missing from storage does not stop a purge", func(t *testing.T) {
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
rows := seedVariantFiles(env.files, env.storage, "hero", "x", 3, 10)
env.storage.failDelete[rows[1].CachedName] = domain.ErrBlobNotFound
recorder := env.purgeCache(`{"scope": "all"}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"all","purged_files":3,"purged_bytes":30,"failed_files":0}`)
if env.files.rowCount() != 0 {
t.Errorf("rows = %d", env.files.rowCount())
}
})
invalid := []struct {
body string
status int
message string
}{
{`{"scope": "everything"}`, 422, apperr.CachePurgeScopeErrorMessage},
{`{"scope": "ALL"}`, 422, apperr.CachePurgeScopeErrorMessage},
{`{}`, 422, apperr.CachePurgeScopeErrorMessage},
{`{"scope": "variant"}`, 422, apperr.CachePurgeTargetErrorMessage},
{`{"scope": "variant", "variant": ""}`, 422, apperr.CachePurgeTargetErrorMessage},
{`{"scope": "image"}`, 422, apperr.CachePurgeTargetErrorMessage},
{`{"scope": "image", "image_id": 0}`, 422, apperr.CachePurgeTargetErrorMessage},
{`{"scope": "image", "image_id": -1}`, 422, apperr.CachePurgeTargetErrorMessage},
{`{"scope": "variant", "variant": "missing"}`, 404, apperr.VariantNotFoundErrorMessage},
{`{"scope": "image", "image_id": 9999}`, 404, "Image not found"},
{`not json`, 400, apperr.BodyErrorMessage},
}
for _, row := range invalid {
t.Run("A request that cannot be served: "+row.body, func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(row.body)
assertStatusAndBody(t, recorder, row.status, row.message)
if env.files.rowCount() != 1284 || len(env.storage.deletedNames()) != 0 || env.events() != 0 {
t.Error("a blob or row was deleted or an event was recorded")
}
})
}
ignored := map[string]string{"all": `"purged_files":1284`, "unused": `"purged_files":312`}
for scope, want := range ignored {
t.Run("A target given for a scope that ignores it is ignored: "+scope, func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
recorder := env.purgeCache(`{"scope": "` + scope + `", "variant": "hero", "image_id": 1}`)
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), want) {
t.Errorf("response = %d %s", recorder.Code, recorder.Body.String())
}
})
}
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func TestImageVariantsCachePartialPurge(t *testing.T) {
failingEnv := func(t *testing.T) (*variantEnv, int64) {
t.Helper()
env := newVariantEnv(t)
env.seedDefaultCache()
page, _ := env.files.ListPage(domain.VariantFileFilter{}, 0, 2000)
var keptBytes int64
for index, row := range page {
if index < 644 {
env.storage.failDelete[row.CachedName] = errors.New("storage down")
continue
}
keptBytes += row.Size
}
return env, keptBytes
}
t.Run("A partial purge answers 200 with the failed count", func(t *testing.T) {
env, purgedBytes := failingEnv(t)
recorder := env.purgeCache(`{"scope": "all"}`)
want := `{"scope":"all","purged_files":640,"purged_bytes":` + itoa64(purgedBytes) + `,"failed_files":644}`
assertStatusAndBody(t, recorder, 200, want)
if env.files.rowCount() != 644 {
t.Errorf("rows = %d", env.files.rowCount())
}
})
t.Run("Retrying removes the rest", func(t *testing.T) {
env, _ := failingEnv(t)
env.purgeCache(`{"scope": "all"}`)
env.storage.failDelete = map[string]error{}
recorder := env.purgeCache(`{"scope": "all"}`)
body := decodeBody(t, recorder)
if body["purged_files"] != 644.0 || body["failed_files"] != 0.0 {
t.Errorf("body = %v", body)
}
})
t.Run("A failure to list the rows answers 500", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.files.failPage = errors.New("list down")
recorder := env.purgeCache(`{"scope": "all"}`)
assertStatusAndBody(t, recorder, 500, "Internal server error")
})
t.Run("Rows are processed in bounded batches", func(t *testing.T) {
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
for index := 0; index < 5000; index++ {
env.files.Save(domain.VariantFile{VariantName: "hero", SourceName: "bulk-" + itoa64(int64(index)), CachedName: "bulk-" + itoa64(int64(index)), Size: 1})
}
recorder := env.purgeCache(`{"scope": "all"}`)
if recorder.Code != 200 || decodeBody(t, recorder)["purged_files"] != 5000.0 {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
if len(env.files.pageLimits) < 10 {
t.Errorf("pages = %d", len(env.files.pageLimits))
}
for _, limit := range env.files.pageLimits {
if limit > 500 {
t.Errorf("page limit = %d", limit)
}
}
})
}
func itoa64(value int64) string {
return strconv.FormatInt(value, 10)
}
func TestImageVariantsCachePurgeActivity(t *testing.T) {
scopes := []struct {
body string
action string
target string
params string
}{
{`{"scope": "all"}`, "media.variant_purge_all", `{"type":"","id":"","name":""}`, `{"purged_bytes":222298112,"purged_files":1284}`},
{`{"scope": "unused"}`, "media.variant_purge_unused", `{"type":"","id":"","name":""}`, `{"older_than_days":30,"purged_bytes":42991616,"purged_files":312}`},
{`{"scope": "variant", "variant": "hero"}`, "media.variant_purge", `{"type":"image_variant","id":"hero","name":"hero"}`, `{"purged_bytes":88080384,"purged_files":244}`},
{`{"scope": "image", "image_id": 1}`, "media.variant_purge_image", `{"type":"image","id":"1","name":"north-wall.jpg"}`, `{"purged_bytes":6082765,"purged_files":6}`},
}
for _, scope := range scopes {
t.Run("A purge that removes files is recorded: "+scope.action, func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.purgeCache(scope.body)
event := onlyEventOf(t, env, scope.action)
if event.Category != "media" || event.Outcome != "success" || marshalled(t, event.Target) != scope.target || marshalled(t, event.Params) != scope.params {
t.Errorf("event = %+v, target = %s, params = %s", event, marshalled(t, event.Target), marshalled(t, event.Params))
}
})
}
t.Run("A partial failure is a success that names the failed count", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
page, _ := env.files.ListPage(domain.VariantFileFilter{}, 0, 2000)
var keptBytes int64
for index, row := range page {
if index < 644 {
env.storage.failDelete[row.CachedName] = errors.New("storage down")
continue
}
keptBytes += row.Size
}
env.purgeCache(`{"scope": "all"}`)
event := onlyEventOf(t, env, "media.variant_purge_all")
want := `{"failed_files":644,"purged_bytes":` + itoa64(keptBytes) + `,"purged_files":640}`
if event.Outcome != "success" || marshalled(t, event.Params) != want {
t.Errorf("event = %+v, params = %s", event, marshalled(t, event.Params))
}
})
t.Run("Every file failing is a failed event with a plain reason", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
page, _ := env.files.ListPage(domain.VariantFileFilter{}, 0, 2000)
for _, row := range page {
env.storage.failDelete[row.CachedName] = errors.New("storage down")
}
recorder := env.purgeCache(`{"scope": "all"}`)
body := decodeBody(t, recorder)
event := onlyEventOf(t, env, "media.variant_purge_all")
if recorder.Code != 200 || body["purged_files"] != 0.0 || body["failed_files"] != 1284.0 || event.Outcome != "failed" || event.FailureReason == nil || *event.FailureReason != "Something went wrong on our side. Please try again." {
t.Errorf("response = %d %v, event = %+v", recorder.Code, body, event)
}
})
nothing := map[string]func(*variantEnv) string{
"the owner purges all on an empty cache": func(env *variantEnv) string { return `{"scope": "all"}` },
"the owner purges unused when nothing is unused": func(env *variantEnv) string {
env.seedDefaultCache()
for _, row := range env.files.rowsOf("hero") {
env.files.setReference(row.ID, env.now)
}
for _, row := range env.files.rowsOf("thumb") {
env.files.setReference(row.ID, env.now)
}
return `{"scope": "unused"}`
},
"the owner purges a variant that has no file": func(env *variantEnv) string {
seedVariant(env.variants, "banner", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
return `{"scope": "variant", "variant": "banner"}`
},
"the owner purges an image that has no file": func(env *variantEnv) string {
env.seedImage(50, "nofile.png")
return `{"scope": "image", "image_id": 50}`
},
"the owner sends an unknown scope": func(env *variantEnv) string { return `{"scope": "everything"}` },
"the owner purges an unknown variant": func(env *variantEnv) string { return `{"scope": "variant", "variant": "ghost"}` },
"the owner purges an unknown image": func(env *variantEnv) string { return `{"scope": "image", "image_id": 777}` },
}
for name, prepare := range nothing {
t.Run("Nothing removed, nothing recorded: "+name, func(t *testing.T) {
env := newVariantEnv(t)
body := prepare(env)
env.purgeCache(body)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
}
t.Run("Nothing removed, nothing recorded: the owner sends GET /v1/image-variants/cache", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.send(http.MethodGet, cacheStatsPath, "", variantOwnerId)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
t.Run("A failing audit write never changes the response", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
env.audit.probe.appendErr = errors.New("audit store down")
recorder := env.purgeCache(`{"scope": "all"}`)
assertStatusAndBody(t, recorder, 200, `{"scope":"all","purged_files":1284,"purged_bytes":222298112,"failed_files":0}`)
if env.files.rowCount() != 0 {
t.Errorf("rows = %d", env.files.rowCount())
}
})
t.Run("The events read back through the existing activity API", func(t *testing.T) {
env := newVariantEnv(t)
env.seedDefaultCache()
for _, body := range []string{`{"scope": "image", "image_id": 1}`, `{"scope": "variant", "variant": "hero"}`, `{"scope": "unused"}`, `{"scope": "all"}`} {
env.purgeCache(body)
}
recorder := env.send(http.MethodGet, "/v1/activity?category=media", "", variantOwnerId)
items := decodeBody(t, recorder)["items"].([]any)
actions := []string{}
for _, item := range items {
actions = append(actions, item.(map[string]any)["action"].(string))
}
want := []string{"media.variant_purge_all", "media.variant_purge_unused", "media.variant_purge", "media.variant_purge_image"}
if recorder.Code != 200 || strings.Join(actions, ",") != strings.Join(want, ",") {
t.Errorf("status = %d, actions = %v", recorder.Code, actions)
}
})
}