package imgcache import ( "bytes" "io" "log/slog" "os" "path/filepath" "strings" "testing" "sneak.berlin/go/pixa/internal/imagerequest" ) // reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does // when it starts, and returns what the pass logged. func reconcileAsAtStartup(t *testing.T, cache *Cache) string { t.Helper() var logBuf bytes.Buffer cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil)) err := cache.reconcileAccounting(t.Context()) if err != nil { t.Fatalf("reconcileAccounting() error = %v", err) } t.Logf("reconciliation log: %s", logBuf.String()) return logBuf.String() } // assertWarningNames checks that logs hold a warning naming the file at // path. func assertWarningNames(t *testing.T, logs, path string) { t.Helper() for line := range strings.Lines(logs) { if strings.Contains(line, `"level":"WARN"`) && strings.Contains(line, `"path":"`+path+`"`) { return } } t.Errorf("reconciliation logged no warning naming %s", path) } // TestReconciliationRemovesDamagedFiles empties a cached source file and a // cached variant file, or cuts them to half their size, as a crash or a // restored snapshot could. It checks that the reconciliation pass removes // each file and its row, with a warning naming the file. func TestReconciliationRemovesDamagedFiles(t *testing.T) { t.Parallel() content := []byte("the whole cached image") for name, size := range map[string]int64{ "empty": 0, "partial": int64(len(content) / 2), } { t.Run(name, func(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) sourceKey := storeEvictionTestSource(t, cache, testHostCDN, testPathCat, content) storeEvictionTestVariant(t, cache, testVariantKeyOne, content) sourcePath := cache.srcContent.keyToPath(sourceKey) variantPath := cache.variants.keyToPath(testVariantKeyOne) for _, path := range []string{sourcePath, variantPath} { err := os.Truncate(path, size) if err != nil { t.Fatalf("cutting %s to %d bytes: %v", path, size, err) } } logs := reconcileAsAtStartup(t, cache) assertWarningNames(t, logs, sourcePath) assertWarningNames(t, logs, variantPath) if cache.srcContent.Exists(sourceKey) { t.Error("the damaged source file is still on disk") } if cache.variants.Exists(testVariantKeyOne) { t.Error("the damaged variant file is still on disk") } rows := countRows(t, cache, `SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(sourceKey)) if rows != 0 { t.Errorf("the damaged source file kept %d rows, want 0", rows) } rows = countRows(t, cache, `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(testVariantKeyOne)) if rows != 0 { t.Errorf("the damaged variant file kept %d rows, want 0", rows) } }) } } // TestReconciliationRemovesEmptyVariantFileWithoutRow checks that the // reconciliation pass removes an empty variant file that has no // accounting row, with a warning naming it, instead of adding a row for // it that would have it served empty. func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) _, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil)) if err != nil { t.Fatalf("failed to store an empty variant file: %v", err) } logs := reconcileAsAtStartup(t, cache) assertWarningNames(t, logs, cache.variants.keyToPath(testVariantKeyOne)) if cache.variants.Exists(testVariantKeyOne) { t.Error("the empty variant file is still on disk") } rows := countRows(t, cache, `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(testVariantKeyOne)) if rows != 0 { t.Errorf("the empty variant file got %d accounting rows, want 0", rows) } } // TestContentStorage_StoreReplacesFileOfAnotherSize checks that a // store over a file of another size at the source's path, such as a // partial file a crash left, replaces it with the content. func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) { t.Parallel() storage, err := NewContentStorage(t.TempDir()) if err != nil { t.Fatalf("NewContentStorage() error = %v", err) } content := []byte("the whole source image") key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key() path := storage.keyToPath(key) err = os.MkdirAll(filepath.Dir(path), StorageDirPerm) if err != nil { t.Fatalf("creating the source's directory: %v", err) } err = os.WriteFile(path, content[:5], StorageFilePerm) if err != nil { t.Fatalf("writing a partial file: %v", err) } _, err = storage.Store(key, content) if err != nil { t.Fatalf("Store() error = %v", err) } stored, _, err := storage.LoadWithSize(key) if err != nil { t.Fatalf("LoadWithSize() error = %v", err) } defer func() { _ = stored.Close() }() got, err := io.ReadAll(stored) if err != nil { t.Fatalf("reading the stored content: %v", err) } if !bytes.Equal(got, content) { t.Errorf("stored file = %q, want %q", got, content) } }