This commit is contained in:
2025-10-13 13:41:34 +02:00
parent 90f10143da
commit 4b62a9a64b
23 changed files with 679 additions and 196 deletions

134
store/boltdb.go Normal file
View File

@@ -0,0 +1,134 @@
package store
import (
"encoding/json"
"log/slog"
"time"
"go.etcd.io/bbolt"
)
var pastesBucket = []byte("pastes")
// Paste represents the data stored for each paste.
type Paste struct {
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
}
// BoltStore is a bbolt implementation of the Store interface.
type BoltStore struct {
db *bbolt.DB
}
// NewBoltStore creates a new BoltStore and initializes the database.
func NewBoltStore(path string) (*BoltStore, error) {
db, err := bbolt.Open(path, 0600, nil)
if err != nil {
return nil, err
}
err = db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(pastesBucket)
return err
})
if err != nil {
return nil, err
}
return &BoltStore{db: db}, nil
}
// Get retrieves a value from the store.
func (s *BoltStore) Get(key string) (string, bool, error) {
var paste Paste
err := s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
val := b.Get([]byte(key))
if val == nil {
return nil // Not found
}
return json.Unmarshal(val, &paste)
})
if err != nil {
return "", false, err
}
if paste.Content == "" {
return "", false, nil
}
return paste.Content, true, nil
}
// Set adds a value to the store with a timestamp.
func (s *BoltStore) Set(key, value string) error {
return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
paste := Paste{
Content: value,
CreatedAt: time.Now(),
}
encoded, err := json.Marshal(paste)
if err != nil {
return err
}
return b.Put([]byte(key), encoded)
})
}
// Del removes a value from the store.
func (s *BoltStore) Del(key string) error {
return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
return b.Delete([]byte(key))
})
}
// Cleanup iterates through all pastes and deletes those older than maxAge.
func (s *BoltStore) Cleanup(maxAge time.Duration) {
slog.Info("running cleanup for old pastes")
var keysToDelete [][]byte
err := s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var paste Paste
if err := json.Unmarshal(v, &paste); err != nil {
slog.Error("failed to unmarshal paste during cleanup", "key", string(k), "error", err)
continue
}
if time.Since(paste.CreatedAt) > maxAge {
keysToDelete = append(keysToDelete, k)
}
}
return nil
})
if err != nil {
slog.Error("failed to view pastes for cleanup", "error", err)
return
}
if len(keysToDelete) > 0 {
err = s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
for _, k := range keysToDelete {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
if err != nil {
slog.Error("failed to delete old pastes", "error", err)
} else {
slog.Info("cleanup finished", "deleted_count", len(keysToDelete))
}
} else {
slog.Info("no old pastes to delete")
}
}
// Close closes the database connection.
func (s *BoltStore) Close() error {
return s.db.Close()
}

97
store/boltdb_test.go Normal file
View File

@@ -0,0 +1,97 @@
package store
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"go.etcd.io/bbolt"
)
// setupTestDB creates a temporary BoltDB instance for testing and returns a cleanup function.
func setupTestDB(t *testing.T) (*BoltStore, func()) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := NewBoltStore(dbPath)
assert.NoError(t, err)
cleanup := func() {
if err := store.Close(); err != nil {
slog.Error("failed to close store", "error", err)
}
if err := os.RemoveAll(dir); err != nil {
slog.Error("failed to remove temp directory", "dir", dir, "error", err)
}
}
return store, cleanup
}
// TestBoltStore_SetGetDel tests the Set, Get, and Del methods of the BoltStore.
func TestBoltStore_SetGetDel(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Test Set and Get
err := store.Set("key1", "value1")
assert.NoError(t, err)
val, ok, err := store.Get("key1")
assert.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "value1", val)
// Test Get non-existent key
_, ok, err = store.Get("non-existent")
assert.NoError(t, err)
assert.False(t, ok)
// Test Delete
err = store.Del("key1")
assert.NoError(t, err)
_, ok, err = store.Get("key1")
assert.NoError(t, err)
assert.False(t, ok)
}
// TestBoltStore_Cleanup tests the Cleanup method of the BoltStore.
func TestBoltStore_Cleanup(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
// Set one fresh and one stale paste
err := store.Set("fresh", "content")
assert.NoError(t, err)
err = store.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
stalePaste := Paste{
Content: "stale",
CreatedAt: time.Now().Add(-2 * time.Hour),
}
encoded, err := json.Marshal(stalePaste)
if err != nil {
return err
}
return b.Put([]byte("stale"), encoded)
})
assert.NoError(t, err)
// Cleanup pastes older than 1 hour
store.Cleanup(1 * time.Hour)
// Check that fresh paste still exists
_, ok, err := store.Get("fresh")
assert.NoError(t, err)
assert.True(t, ok)
// Check that stale paste was deleted
_, ok, err = store.Get("stale")
assert.NoError(t, err)
assert.False(t, ok)
}

View File

@@ -1,36 +1,42 @@
package store
import "sync"
import (
"sync"
)
// MemoryStore is an in-memory implementation of the Store interface.
type MemoryStore struct {
data map[string]string
mx sync.RWMutex
mu sync.RWMutex
}
// NewMemoryStore creates a new MemoryStore.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
data: make(map[string]string),
}
}
func (s *MemoryStore) Get(key string) (string, bool) {
s.mx.RLock()
defer s.mx.RUnlock()
content, exists := s.data[key]
return content, exists
// Get retrieves a value from the store.
func (s *MemoryStore) Get(key string) (string, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.data[key]
return val, ok, nil
}
func (s *MemoryStore) Set(key string, value string) {
s.mx.Lock()
defer s.mx.Unlock()
// Set adds a value to the store.
func (s *MemoryStore) Set(key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data[key] = value
return nil
}
func (s *MemoryStore) Del(key string) {
s.mx.Lock()
defer s.mx.Unlock()
// Del removes a value from the store.
func (s *MemoryStore) Del(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
return nil
}

View File

@@ -1,44 +1,33 @@
package store
import "testing"
import (
"testing"
func TestMemoryStoreGet(t *testing.T) {
"github.com/stretchr/testify/assert"
)
func TestMemoryStore(t *testing.T) {
store := NewMemoryStore()
store.Set("key", "value")
if val, _ := store.Get("key"); val != "value" {
t.Errorf("get() = %s, want %s", val, "value")
}
}
func TestMemoryStoreExists(t *testing.T) {
store := NewMemoryStore()
if _, exists := store.Get("something"); exists {
t.Errorf("get() = %t, want %t", exists, false)
}
}
func TestMemoryStoreOverride(t *testing.T) {
store := NewMemoryStore()
store.Set("key", "value")
store.Set("key", "new_value")
if val, _ := store.Get("key"); val != "new_value" {
t.Errorf("get() = %s, want %s", val, "new_value")
}
}
func TestMemoryStoreDelete(t *testing.T) {
store := NewMemoryStore()
store.Set("key", "value")
if _, exists := store.Get("key"); !exists {
t.Errorf("get() = %t, want %t", exists, true)
}
store.Del("key")
if val, _ := store.Get("key"); val != "" {
t.Errorf("del() = %s, want %s", val, "")
}
// Test Set and Get
err := store.Set("key1", "value1")
assert.NoError(t, err)
val, ok, err := store.Get("key1")
assert.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "value1", val)
// Test Get non-existent key
_, ok, err = store.Get("non-existent")
assert.NoError(t, err)
assert.False(t, ok)
// Test Delete
err = store.Del("key1")
assert.NoError(t, err)
_, ok, err = store.Get("key1")
assert.NoError(t, err)
assert.False(t, ok)
}

View File

@@ -1,7 +1,8 @@
package store
type store interface {
Get(key string) (string, bool)
Set(key string, value string)
Del(key string)
// Store is the interface for a key-value store.
type Store interface {
Get(key string) (string, bool, error)
Set(key, value string) error
Del(key string) error
}