Add storage tests for URL parsing and every in-process backend (closes #66)
check / check (pull_request) Failing after 1s
check / check (pull_request) Failing after 1s
internal/storage had no tests. This adds table-driven ParseStorageURL coverage and a shared Storer conformance suite, defined once in conformance_test.go and run against every backend that can run in-process: file:// over a temp dir and s3:// over the same in-memory S3 harness (gofakes3 + s3mem) that internal/s3 and the not-found test use, so a new backend inherits the contract by passing its constructor. The rclone backend gets construction and argument-shaping tests via rclone's in-process ":local" backend, plus the missing-remote error path; a comment records that its data-plane operations need a configured remote and so cannot run in a unit test. Tests only; no production code changed and no defect surfaced. Model: opus-4-8
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// runStorerConformance is the shared Storer contract. Every backend that
|
||||
// can run in-process is expected to pass it: TestFileStorer runs it against
|
||||
// file://, TestS3Storer against s3://. A new backend inherits this coverage
|
||||
// by passing its own constructor, so the contract is defined once.
|
||||
//
|
||||
// It exercises the public Storer interface: round-trip, stat, list with
|
||||
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
|
||||
// Get and Stat. Each section takes its own fresh backend instance, so the
|
||||
// order of sections never matters and no section sees another's objects.
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user