From c21789557c5f16c817a3e4a96330662fbaab7bc1 Mon Sep 17 00:00:00 2001 From: Tutus Development Date: Sun, 21 Dec 2025 06:58:20 -0500 Subject: [PATCH] Add BlockStorage and StateStorage to local adapter with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full local storage adapter with three interfaces: - Provider: Basic object storage (Put, Get, Delete, Exists, List, Head) - BlockStorage: Block-indexed storage (PutBlock, GetBlock, GetBlockRange) - StateStorage: State snapshot storage (PutState, GetState, GetLatestState) Implementation details: - Content-addressed storage using SHA256 for object IDs - Directory sharding for filesystem performance: - Objects: first 2 chars of hash (e.g., objects/91/91f0...) - Blocks: every 10,000 (e.g., blocks/0001/12345.block) - States: every 10,000 (e.g., states/0001/12345.state) - Metadata JSON sidecar files with content type and attributes - Thread-safe with sync.RWMutex - Size quota enforcement across all directories Test coverage (30 tests): - Provider interface: Put/Get, Delete, Exists, Head, List, quota - BlockStorage: PutGetBlock, GetBlockRange, GetLatestBlockIndex - StateStorage: PutGetState, GetLatestState, quota enforcement - Path helpers and interface compliance verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/storage/local/adapter.go | 288 +++++++++++++- pkg/storage/local/adapter_test.go | 604 ++++++++++++++++++++++++++++++ 2 files changed, 881 insertions(+), 11 deletions(-) create mode 100644 pkg/storage/local/adapter_test.go diff --git a/pkg/storage/local/adapter.go b/pkg/storage/local/adapter.go index 803c02f..aa13cc5 100644 --- a/pkg/storage/local/adapter.go +++ b/pkg/storage/local/adapter.go @@ -53,7 +53,7 @@ func New(cfg Config) (*Adapter, error) { } // Create subdirectories - for _, sub := range []string{"objects", "meta"} { + for _, sub := range []string{"objects", "meta", "blocks", "states"} { if err := os.MkdirAll(filepath.Join(path, sub), 0755); err != nil { return nil, fmt.Errorf("local: create %s directory: %w", sub, err) } @@ -114,6 +114,10 @@ func (a *Adapter) Put(ctx context.Context, data io.Reader, opts storage.PutOptio } metaPath := a.metaPath(id) + if err := os.MkdirAll(filepath.Dir(metaPath), 0755); err != nil { + os.Remove(objPath) // Clean up on error + return storage.ObjectID{}, fmt.Errorf("local: create meta dir: %w", err) + } metaData, _ := json.Marshal(meta) if err := os.WriteFile(metaPath, metaData, 0644); err != nil { os.Remove(objPath) // Clean up on error @@ -274,6 +278,246 @@ func (a *Adapter) Close() error { return nil } +// ============================================================================= +// BlockStorage Interface Implementation +// ============================================================================= + +// PutBlock stores a block with its index. +func (a *Adapter) PutBlock(ctx context.Context, index uint32, data []byte) (storage.ObjectID, error) { + a.mu.Lock() + defer a.mu.Unlock() + + // Check size limits + if a.maxSize > 0 { + currentSize, _ := a.calculateSize() + if currentSize+int64(len(data)) > a.maxSize { + return storage.ObjectID{}, storage.ErrQuotaExceeded + } + } + + // Write block file + blockPath := a.blockPath(index) + if err := os.MkdirAll(filepath.Dir(blockPath), 0755); err != nil { + return storage.ObjectID{}, fmt.Errorf("local: create block dir: %w", err) + } + + if err := os.WriteFile(blockPath, data, 0644); err != nil { + return storage.ObjectID{}, fmt.Errorf("local: write block: %w", err) + } + + // Generate content hash for ID + hash := sha256.Sum256(data) + id := hex.EncodeToString(hash[:]) + + return storage.ObjectID{ + Provider: providerName, + Container: "blocks", + ID: id, + }, nil +} + +// GetBlock retrieves a block by its index. +func (a *Adapter) GetBlock(ctx context.Context, index uint32) ([]byte, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + blockPath := a.blockPath(index) + data, err := os.ReadFile(blockPath) + if err != nil { + if os.IsNotExist(err) { + return nil, storage.ErrNotFound + } + return nil, fmt.Errorf("local: read block: %w", err) + } + + return data, nil +} + +// GetBlockRange retrieves multiple blocks efficiently. +func (a *Adapter) GetBlockRange(ctx context.Context, start, end uint32) ([][]byte, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + if start > end { + return nil, fmt.Errorf("local: invalid block range: start %d > end %d", start, end) + } + + blocks := make([][]byte, 0, end-start+1) + for i := start; i <= end; i++ { + blockPath := a.blockPath(i) + data, err := os.ReadFile(blockPath) + if err != nil { + if os.IsNotExist(err) { + return nil, storage.ErrNotFound + } + return nil, fmt.Errorf("local: read block %d: %w", i, err) + } + blocks = append(blocks, data) + } + + return blocks, nil +} + +// GetLatestBlockIndex returns the highest block index in storage. +func (a *Adapter) GetLatestBlockIndex(ctx context.Context) (uint32, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + blocksDir := filepath.Join(a.basePath, "blocks") + var latest uint32 + var found bool + + err := filepath.Walk(blocksDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if info.IsDir() { + return nil + } + + // Parse block index from filename + name := info.Name() + if !strings.HasSuffix(name, ".block") { + return nil + } + + var index uint32 + if _, err := fmt.Sscanf(name, "%d.block", &index); err != nil { + return nil + } + + if !found || index > latest { + latest = index + found = true + } + return nil + }) + + if err != nil { + return 0, fmt.Errorf("local: scan blocks: %w", err) + } + + if !found { + return 0, storage.ErrNotFound + } + + return latest, nil +} + +// ============================================================================= +// StateStorage Interface Implementation +// ============================================================================= + +// PutState stores a state snapshot at a specific height. +func (a *Adapter) PutState(ctx context.Context, height uint32, data io.Reader) (storage.ObjectID, error) { + a.mu.Lock() + defer a.mu.Unlock() + + // Read all data + content, err := io.ReadAll(data) + if err != nil { + return storage.ObjectID{}, fmt.Errorf("local: read state data: %w", err) + } + + // Check size limits + if a.maxSize > 0 { + currentSize, _ := a.calculateSize() + if currentSize+int64(len(content)) > a.maxSize { + return storage.ObjectID{}, storage.ErrQuotaExceeded + } + } + + // Write state file + statePath := a.statePath(height) + if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil { + return storage.ObjectID{}, fmt.Errorf("local: create state dir: %w", err) + } + + if err := os.WriteFile(statePath, content, 0644); err != nil { + return storage.ObjectID{}, fmt.Errorf("local: write state: %w", err) + } + + // Generate content hash for ID + hash := sha256.Sum256(content) + id := hex.EncodeToString(hash[:]) + + return storage.ObjectID{ + Provider: providerName, + Container: "states", + ID: id, + }, nil +} + +// GetState retrieves the state snapshot for a given height. +func (a *Adapter) GetState(ctx context.Context, height uint32) (io.ReadCloser, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + statePath := a.statePath(height) + file, err := os.Open(statePath) + if err != nil { + if os.IsNotExist(err) { + return nil, storage.ErrNotFound + } + return nil, fmt.Errorf("local: open state: %w", err) + } + + return file, nil +} + +// GetLatestState returns the most recent state snapshot. +func (a *Adapter) GetLatestState(ctx context.Context) (uint32, io.ReadCloser, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + statesDir := filepath.Join(a.basePath, "states") + var latest uint32 + var found bool + + err := filepath.Walk(statesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if info.IsDir() { + return nil + } + + // Parse height from filename + name := info.Name() + if !strings.HasSuffix(name, ".state") { + return nil + } + + var height uint32 + if _, err := fmt.Sscanf(name, "%d.state", &height); err != nil { + return nil + } + + if !found || height > latest { + latest = height + found = true + } + return nil + }) + + if err != nil { + return 0, nil, fmt.Errorf("local: scan states: %w", err) + } + + if !found { + return 0, nil, storage.ErrNotFound + } + + // Open the latest state file + statePath := a.statePath(latest) + file, err := os.Open(statePath) + if err != nil { + return 0, nil, fmt.Errorf("local: open latest state: %w", err) + } + + return latest, file, nil +} + // objectPath returns the filesystem path for an object. func (a *Adapter) objectPath(id string) string { // Use first 2 chars as subdirectory for better filesystem performance @@ -291,6 +535,22 @@ func (a *Adapter) metaPath(id string) string { return filepath.Join(a.basePath, "meta", id+".json") } +// blockPath returns the filesystem path for a block by index. +func (a *Adapter) blockPath(index uint32) string { + // Use first 4 digits as subdirectory for better filesystem performance + // e.g., block 12345 -> blocks/0001/12345.block + subdir := fmt.Sprintf("%04d", index/10000) + return filepath.Join(a.basePath, "blocks", subdir, fmt.Sprintf("%d.block", index)) +} + +// statePath returns the filesystem path for a state snapshot by height. +func (a *Adapter) statePath(height uint32) string { + // Use first 4 digits as subdirectory for better filesystem performance + // e.g., state 12345 -> states/0001/12345.state + subdir := fmt.Sprintf("%04d", height/10000) + return filepath.Join(a.basePath, "states", subdir, fmt.Sprintf("%d.state", height)) +} + // objectMeta holds object metadata. type objectMeta struct { Size int64 `json:"size"` @@ -313,20 +573,26 @@ func (a *Adapter) loadMeta(id string) (objectMeta, error) { return meta, err } -// calculateSize returns the total size of stored objects. +// calculateSize returns the total size of all stored data. func (a *Adapter) calculateSize() (int64, error) { var total int64 - objectsDir := filepath.Join(a.basePath, "objects") - err := filepath.Walk(objectsDir, func(path string, info os.FileInfo, err error) error { - if err != nil { + // Calculate size across all storage directories + for _, subdir := range []string{"objects", "blocks", "states"} { + dir := filepath.Join(a.basePath, subdir) + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + total += info.Size() + } return nil + }) + if err != nil { + return total, err } - if !info.IsDir() { - total += info.Size() - } - return nil - }) + } - return total, err + return total, nil } diff --git a/pkg/storage/local/adapter_test.go b/pkg/storage/local/adapter_test.go new file mode 100644 index 0000000..2ca0437 --- /dev/null +++ b/pkg/storage/local/adapter_test.go @@ -0,0 +1,604 @@ +package local + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tutus-one/tutus-chain/pkg/storage" +) + +func TestNew(t *testing.T) { + tmpDir := t.TempDir() + + adapter, err := New(Config{Path: tmpDir}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer adapter.Close() + + // Check subdirectories were created + for _, sub := range []string{"objects", "meta", "blocks", "states"} { + path := filepath.Join(tmpDir, sub) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("subdirectory %s not created", sub) + } + } +} + +func TestNew_DefaultPath(t *testing.T) { + // Clean up default path after test + defer os.RemoveAll("./storage") + + adapter, err := New(Config{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer adapter.Close() + + if _, err := os.Stat("./storage"); os.IsNotExist(err) { + t.Error("default storage directory not created") + } +} + +func TestAdapter_Name(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + if got := adapter.Name(); got != "local" { + t.Errorf("Name() = %v, want %v", got, "local") + } +} + +func TestAdapter_PutGet(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + data := []byte("test data") + + // Put + id, err := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{ + ContentType: "text/plain", + Attributes: map[string]string{"key": "value"}, + }) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + + if id.Provider != "local" { + t.Errorf("Put() Provider = %v, want %v", id.Provider, "local") + } + + // Get + reader, err := adapter.Get(ctx, id) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer reader.Close() + + got, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + + if !bytes.Equal(got, data) { + t.Errorf("Get() = %v, want %v", got, data) + } +} + +func TestAdapter_Get_NotFound(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + _, err := adapter.Get(ctx, storage.ObjectID{ID: "nonexistent"}) + + if err != storage.ErrNotFound { + t.Errorf("Get() error = %v, want %v", err, storage.ErrNotFound) + } +} + +func TestAdapter_Exists(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + data := []byte("test data") + + // Put + id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{}) + + // Exists should return true + exists, err := adapter.Exists(ctx, id) + if err != nil { + t.Fatalf("Exists() error = %v", err) + } + if !exists { + t.Error("Exists() = false, want true") + } + + // Non-existent should return false + exists, err = adapter.Exists(ctx, storage.ObjectID{ID: "nonexistent"}) + if err != nil { + t.Fatalf("Exists() error = %v", err) + } + if exists { + t.Error("Exists() = true, want false") + } +} + +func TestAdapter_Delete(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + data := []byte("test data") + + // Put + id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{}) + + // Delete + err := adapter.Delete(ctx, id) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + + // Should not exist anymore + exists, _ := adapter.Exists(ctx, id) + if exists { + t.Error("object still exists after Delete()") + } +} + +func TestAdapter_Delete_NotFound(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + err := adapter.Delete(ctx, storage.ObjectID{ID: "nonexistent"}) + + if err != storage.ErrNotFound { + t.Errorf("Delete() error = %v, want %v", err, storage.ErrNotFound) + } +} + +func TestAdapter_Head(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + data := []byte("test data") + + // Put + id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{ + ContentType: "text/plain", + Attributes: map[string]string{"key": "value"}, + }) + + // Head + info, err := adapter.Head(ctx, id) + if err != nil { + t.Fatalf("Head() error = %v", err) + } + + if info.Size != int64(len(data)) { + t.Errorf("Head() Size = %v, want %v", info.Size, len(data)) + } + if info.ContentType != "text/plain" { + t.Errorf("Head() ContentType = %v, want %v", info.ContentType, "text/plain") + } + if info.Attributes["key"] != "value" { + t.Errorf("Head() Attributes[key] = %v, want %v", info.Attributes["key"], "value") + } +} + +func TestAdapter_Head_NotFound(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + _, err := adapter.Head(ctx, storage.ObjectID{ID: "nonexistent"}) + + if err != storage.ErrNotFound { + t.Errorf("Head() error = %v, want %v", err, storage.ErrNotFound) + } +} + +func TestAdapter_List(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + + // Put some objects + ids := make([]storage.ObjectID, 3) + for i := 0; i < 3; i++ { + data := []byte(strings.Repeat("x", i+1)) // Different content = different ID + id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{}) + ids[i] = id + } + + // List all + results, err := adapter.List(ctx, "", storage.ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(results) != 3 { + t.Errorf("List() returned %d items, want 3", len(results)) + } +} + +func TestAdapter_List_MaxResults(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + + // Put some objects + for i := 0; i < 5; i++ { + data := []byte(strings.Repeat("x", i+1)) + adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{}) + } + + // List with limit + results, err := adapter.List(ctx, "", storage.ListOptions{MaxResults: 2}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(results) != 2 { + t.Errorf("List() returned %d items, want 2", len(results)) + } +} + +func TestAdapter_QuotaExceeded(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir(), MaxSize: 100}) + defer adapter.Close() + + ctx := context.Background() + + // First put should succeed + data1 := []byte(strings.Repeat("x", 50)) + _, err := adapter.Put(ctx, bytes.NewReader(data1), storage.PutOptions{}) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + + // Second put should exceed quota + data2 := []byte(strings.Repeat("y", 60)) + _, err = adapter.Put(ctx, bytes.NewReader(data2), storage.PutOptions{}) + if err != storage.ErrQuotaExceeded { + t.Errorf("Put() error = %v, want %v", err, storage.ErrQuotaExceeded) + } +} + +// ============================================================================= +// BlockStorage Tests +// ============================================================================= + +func TestAdapter_PutGetBlock(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + blockData := []byte("block 0 data") + + // PutBlock + id, err := adapter.PutBlock(ctx, 0, blockData) + if err != nil { + t.Fatalf("PutBlock() error = %v", err) + } + + if id.Container != "blocks" { + t.Errorf("PutBlock() Container = %v, want %v", id.Container, "blocks") + } + + // GetBlock + got, err := adapter.GetBlock(ctx, 0) + if err != nil { + t.Fatalf("GetBlock() error = %v", err) + } + + if !bytes.Equal(got, blockData) { + t.Errorf("GetBlock() = %v, want %v", got, blockData) + } +} + +func TestAdapter_GetBlock_NotFound(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + _, err := adapter.GetBlock(ctx, 999) + + if err != storage.ErrNotFound { + t.Errorf("GetBlock() error = %v, want %v", err, storage.ErrNotFound) + } +} + +func TestAdapter_GetBlockRange(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + + // Put several blocks + for i := uint32(0); i < 5; i++ { + data := []byte(strings.Repeat(string(rune('a'+i)), 10)) + _, err := adapter.PutBlock(ctx, i, data) + if err != nil { + t.Fatalf("PutBlock(%d) error = %v", i, err) + } + } + + // Get range + blocks, err := adapter.GetBlockRange(ctx, 1, 3) + if err != nil { + t.Fatalf("GetBlockRange() error = %v", err) + } + + if len(blocks) != 3 { + t.Errorf("GetBlockRange() returned %d blocks, want 3", len(blocks)) + } + + // Verify content + for i, block := range blocks { + expected := []byte(strings.Repeat(string(rune('b'+i)), 10)) + if !bytes.Equal(block, expected) { + t.Errorf("GetBlockRange() block %d = %v, want %v", i+1, block, expected) + } + } +} + +func TestAdapter_GetBlockRange_InvalidRange(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + _, err := adapter.GetBlockRange(ctx, 10, 5) + + if err == nil || !strings.Contains(err.Error(), "invalid block range") { + t.Errorf("GetBlockRange() error = %v, want invalid block range error", err) + } +} + +func TestAdapter_GetBlockRange_NotFound(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + + // Put only block 0 + adapter.PutBlock(ctx, 0, []byte("block 0")) + + // Try to get range that includes missing block + _, err := adapter.GetBlockRange(ctx, 0, 2) + + if err != storage.ErrNotFound { + t.Errorf("GetBlockRange() error = %v, want %v", err, storage.ErrNotFound) + } +} + +func TestAdapter_GetLatestBlockIndex(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + + // No blocks yet + _, err := adapter.GetLatestBlockIndex(ctx) + if err != storage.ErrNotFound { + t.Errorf("GetLatestBlockIndex() error = %v, want %v", err, storage.ErrNotFound) + } + + // Put some blocks + for i := uint32(0); i < 10; i++ { + adapter.PutBlock(ctx, i, []byte("block")) + } + + // Get latest + latest, err := adapter.GetLatestBlockIndex(ctx) + if err != nil { + t.Fatalf("GetLatestBlockIndex() error = %v", err) + } + + if latest != 9 { + t.Errorf("GetLatestBlockIndex() = %v, want 9", latest) + } +} + +func TestAdapter_PutBlock_QuotaExceeded(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir(), MaxSize: 100}) + defer adapter.Close() + + ctx := context.Background() + + // First block should succeed + _, err := adapter.PutBlock(ctx, 0, []byte(strings.Repeat("x", 50))) + if err != nil { + t.Fatalf("PutBlock() error = %v", err) + } + + // Second block should exceed quota + _, err = adapter.PutBlock(ctx, 1, []byte(strings.Repeat("y", 60))) + if err != storage.ErrQuotaExceeded { + t.Errorf("PutBlock() error = %v, want %v", err, storage.ErrQuotaExceeded) + } +} + +// ============================================================================= +// StateStorage Tests +// ============================================================================= + +func TestAdapter_PutGetState(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + stateData := []byte("state at height 0") + + // PutState + id, err := adapter.PutState(ctx, 0, bytes.NewReader(stateData)) + if err != nil { + t.Fatalf("PutState() error = %v", err) + } + + if id.Container != "states" { + t.Errorf("PutState() Container = %v, want %v", id.Container, "states") + } + + // GetState + reader, err := adapter.GetState(ctx, 0) + if err != nil { + t.Fatalf("GetState() error = %v", err) + } + defer reader.Close() + + got, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + + if !bytes.Equal(got, stateData) { + t.Errorf("GetState() = %v, want %v", got, stateData) + } +} + +func TestAdapter_GetState_NotFound(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + _, err := adapter.GetState(ctx, 999) + + if err != storage.ErrNotFound { + t.Errorf("GetState() error = %v, want %v", err, storage.ErrNotFound) + } +} + +func TestAdapter_GetLatestState(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir()}) + defer adapter.Close() + + ctx := context.Background() + + // No states yet + _, _, err := adapter.GetLatestState(ctx) + if err != storage.ErrNotFound { + t.Errorf("GetLatestState() error = %v, want %v", err, storage.ErrNotFound) + } + + // Put some states + for i := uint32(0); i < 5; i++ { + data := []byte(strings.Repeat(string(rune('a'+i)), 10)) + _, err := adapter.PutState(ctx, i, bytes.NewReader(data)) + if err != nil { + t.Fatalf("PutState(%d) error = %v", i, err) + } + } + + // Get latest + height, reader, err := adapter.GetLatestState(ctx) + if err != nil { + t.Fatalf("GetLatestState() error = %v", err) + } + defer reader.Close() + + if height != 4 { + t.Errorf("GetLatestState() height = %v, want 4", height) + } + + got, _ := io.ReadAll(reader) + expected := []byte(strings.Repeat("e", 10)) + if !bytes.Equal(got, expected) { + t.Errorf("GetLatestState() data = %v, want %v", got, expected) + } +} + +func TestAdapter_PutState_QuotaExceeded(t *testing.T) { + adapter, _ := New(Config{Path: t.TempDir(), MaxSize: 100}) + defer adapter.Close() + + ctx := context.Background() + + // First state should succeed + _, err := adapter.PutState(ctx, 0, bytes.NewReader([]byte(strings.Repeat("x", 50)))) + if err != nil { + t.Fatalf("PutState() error = %v", err) + } + + // Second state should exceed quota + _, err = adapter.PutState(ctx, 1, bytes.NewReader([]byte(strings.Repeat("y", 60)))) + if err != storage.ErrQuotaExceeded { + t.Errorf("PutState() error = %v, want %v", err, storage.ErrQuotaExceeded) + } +} + +// ============================================================================= +// Path Helper Tests +// ============================================================================= + +func TestAdapter_blockPath(t *testing.T) { + adapter := &Adapter{basePath: "/storage"} + + tests := []struct { + index uint32 + expected string + }{ + {0, filepath.Join("/storage", "blocks", "0000", "0.block")}, + {9999, filepath.Join("/storage", "blocks", "0000", "9999.block")}, + {10000, filepath.Join("/storage", "blocks", "0001", "10000.block")}, + {12345, filepath.Join("/storage", "blocks", "0001", "12345.block")}, + {99999, filepath.Join("/storage", "blocks", "0009", "99999.block")}, + } + + for _, tt := range tests { + got := adapter.blockPath(tt.index) + if got != tt.expected { + t.Errorf("blockPath(%d) = %v, want %v", tt.index, got, tt.expected) + } + } +} + +func TestAdapter_statePath(t *testing.T) { + adapter := &Adapter{basePath: "/storage"} + + tests := []struct { + height uint32 + expected string + }{ + {0, filepath.Join("/storage", "states", "0000", "0.state")}, + {9999, filepath.Join("/storage", "states", "0000", "9999.state")}, + {10000, filepath.Join("/storage", "states", "0001", "10000.state")}, + {12345, filepath.Join("/storage", "states", "0001", "12345.state")}, + } + + for _, tt := range tests { + got := adapter.statePath(tt.height) + if got != tt.expected { + t.Errorf("statePath(%d) = %v, want %v", tt.height, got, tt.expected) + } + } +} + +// ============================================================================= +// Interface Compliance Tests +// ============================================================================= + +func TestAdapter_ImplementsProvider(t *testing.T) { + var _ storage.Provider = (*Adapter)(nil) +} + +func TestAdapter_ImplementsBlockStorage(t *testing.T) { + var _ storage.BlockStorage = (*Adapter)(nil) +} + +func TestAdapter_ImplementsStateStorage(t *testing.T) { + var _ storage.StateStorage = (*Adapter)(nil) +}