check / check (pull_request) Failing after 1s
internal/storage had no tests. This adds table-driven coverage for ParseStorageURL (each scheme, query parameters, missing components, an unknown scheme, and the fields that decide which backend is built) and a backend-agnostic Storer conformance suite run against the file:// backend over a temp directory: put, get, stat, list with prefix filtering, overwrite, delete, delete-of-missing, and not-found on Get and Stat. Tests only; no production code changed and no defect surfaced. New files (url_parse_test.go, file_backend_test.go) avoid colliding with the s3_test.go and file-backend work in flight on other branches. The conformance helper takes a constructor, so s3:// and rclone:// can adopt it once their harness is wired. Model: opus-4-8
215 lines
5.1 KiB
Go
215 lines
5.1 KiB
Go
package storage_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"reflect"
|
|
"sort"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
)
|
|
|
|
// newFileStorer builds a file:// backend rooted at a fresh temp directory.
|
|
//
|
|
//nolint:ireturn // conformance runs against the Storer interface by design
|
|
func newFileStorer(t *testing.T) storage.Storer {
|
|
t.Helper()
|
|
|
|
s, err := storage.NewFileStorer(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewFileStorer: %v", err)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// TestFileStorer runs the Storer contract against the file:// backend.
|
|
// The conformance helper is backend-agnostic, so a new backend inherits
|
|
// this coverage by passing its own constructor.
|
|
func TestFileStorer(t *testing.T) {
|
|
t.Parallel()
|
|
runStorerConformance(t, newFileStorer)
|
|
}
|
|
|
|
// runStorerConformance exercises the public Storer contract: round-trip,
|
|
// stat, list, overwrite, delete, and not-found behaviour. Each section
|
|
// uses its own backend instance so ordering never matters.
|
|
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
|
t.Helper()
|
|
|
|
conformanceRoundTrip(t, newStorer(t))
|
|
conformanceOverwrite(t, newStorer(t))
|
|
conformanceList(t, newStorer(t))
|
|
conformanceDelete(t, newStorer(t))
|
|
conformanceNotFound(t, newStorer(t))
|
|
}
|
|
|
|
// conformanceRoundTrip stores a nested key, then reads it back and stats it.
|
|
func conformanceRoundTrip(t *testing.T, s storage.Storer) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
key := "blobs/aa/bb/object.bin"
|
|
want := []byte("round-trip payload")
|
|
|
|
err := s.Put(ctx, key, bytes.NewReader(want))
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
got := getBytes(t, s, key)
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("Get returned %q, want %q", got, want)
|
|
}
|
|
|
|
info, err := s.Stat(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
|
|
if info.Key != key {
|
|
t.Errorf("Stat key = %q, want %q", info.Key, key)
|
|
}
|
|
|
|
if info.Size != int64(len(want)) {
|
|
t.Errorf("Stat size = %d, want %d", info.Size, len(want))
|
|
}
|
|
}
|
|
|
|
// conformanceOverwrite checks that a second Put replaces the first.
|
|
func conformanceOverwrite(t *testing.T, s storage.Storer) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
key := "meta/snapshot.json"
|
|
|
|
err := s.Put(ctx, key, bytes.NewReader([]byte("first")))
|
|
if err != nil {
|
|
t.Fatalf("first Put: %v", err)
|
|
}
|
|
|
|
want := []byte("second and longer payload")
|
|
|
|
err = s.Put(ctx, key, bytes.NewReader(want))
|
|
if err != nil {
|
|
t.Fatalf("second Put: %v", err)
|
|
}
|
|
|
|
got := getBytes(t, s, key)
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("after overwrite Get returned %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// conformanceList checks prefix filtering and the empty result for a
|
|
// prefix that matches nothing.
|
|
func conformanceList(t *testing.T, s storage.Storer) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
keys := []string{"blobs/aa/one", "blobs/bb/two", "meta/three"}
|
|
|
|
for _, k := range keys {
|
|
err := s.Put(ctx, k, bytes.NewReader([]byte("data")))
|
|
if err != nil {
|
|
t.Fatalf("Put %q: %v", k, err)
|
|
}
|
|
}
|
|
|
|
if got := listSorted(t, s, ""); !reflect.DeepEqual(got, keys) {
|
|
t.Errorf("List(\"\") = %v, want %v", got, keys)
|
|
}
|
|
|
|
wantBlobs := []string{"blobs/aa/one", "blobs/bb/two"}
|
|
if got := listSorted(t, s, "blobs/"); !reflect.DeepEqual(got, wantBlobs) {
|
|
t.Errorf("List(\"blobs/\") = %v, want %v", got, wantBlobs)
|
|
}
|
|
|
|
if got := listSorted(t, s, "absent/"); len(got) != 0 {
|
|
t.Errorf("List(\"absent/\") = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
// conformanceDelete checks that Delete removes an object and that deleting
|
|
// a missing key is not an error.
|
|
func conformanceDelete(t *testing.T, s storage.Storer) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
key := "blobs/cc/gone.bin"
|
|
|
|
err := s.Put(ctx, key, bytes.NewReader([]byte("temporary")))
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
err = s.Delete(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
_, err = s.Get(ctx, key)
|
|
if !errors.Is(err, storage.ErrNotFound) {
|
|
t.Errorf("Get after Delete error = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
err = s.Delete(ctx, key)
|
|
if err != nil {
|
|
t.Errorf("Delete of missing key = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// conformanceNotFound checks Get and Stat on an absent key.
|
|
func conformanceNotFound(t *testing.T, s storage.Storer) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
key := "never/written"
|
|
|
|
_, err := s.Get(ctx, key)
|
|
if !errors.Is(err, storage.ErrNotFound) {
|
|
t.Errorf("Get error = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
_, err = s.Stat(ctx, key)
|
|
if !errors.Is(err, storage.ErrNotFound) {
|
|
t.Errorf("Stat error = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// getBytes reads a key fully and closes the reader.
|
|
func getBytes(t *testing.T, s storage.Storer, key string) []byte {
|
|
t.Helper()
|
|
|
|
rc, err := s.Get(context.Background(), key)
|
|
if err != nil {
|
|
t.Fatalf("Get %q: %v", key, err)
|
|
}
|
|
|
|
defer func() { _ = rc.Close() }()
|
|
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("read %q: %v", key, err)
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
// listSorted returns the keys under a prefix in a stable order.
|
|
func listSorted(t *testing.T, s storage.Storer, prefix string) []string {
|
|
t.Helper()
|
|
|
|
keys, err := s.List(context.Background(), prefix)
|
|
if err != nil {
|
|
t.Fatalf("List %q: %v", prefix, err)
|
|
}
|
|
|
|
sort.Strings(keys)
|
|
|
|
return keys
|
|
}
|