diff --git a/README.md b/README.md index 470f864..b2237e8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,25 @@ # gotoolkit +[![Go Reference](https://pkg.go.dev/badge/git.nakama.town/fmartingr/gotoolkit.svg)](https://pkg.go.dev/git.nakama.town/fmartingr/gotoolkit) + A set of basic tools to develop Go applications. +# Index + +- [Cache](#cache) +- [Database](#database) +- [Paths](#paths) +- [Service & Servers](#service--servers) +- [Template](#template) + +## Cache + +A basic cache engine to manage the cache of the application. + +```go +cache := cache.NewMemoryCache() +``` + ## Database A basic database engine to manage the connection to a database. @@ -13,6 +31,14 @@ if err != nil { } ``` +## Paths + +A basic utility to manage the paths of the application. + +```go +path := paths.ExpandUser("~/myapp") +``` + ## Service & Servers A basic way to expose servers within one service. diff --git a/cache/file.go b/cache/file.go new file mode 100644 index 0000000..9bd7a31 --- /dev/null +++ b/cache/file.go @@ -0,0 +1,131 @@ +package cache + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "git.nakama.town/fmartingr/gotoolkit/model" +) + +var _ model.Cache = (*FileCache)(nil) + +type FileCache struct { + path string +} + +func (c *FileCache) Get(key string) (result any, err error) { + path := c.getPathForItem(key) + + if _, err := os.Stat(path + ".metadata"); os.IsNotExist(err) { + return result, model.ErrCacheKeyDontExist + } + + metadata, err := c.getMetadata(key) + if err != nil { + return result, fmt.Errorf("error getting metadata: %s", err) + } + + if metadata.TTL != nil && metadata.TTL.Before(time.Now()) { + if err := c.Delete(key); err != nil { + return result, fmt.Errorf("error deleting cache key: %s", err) + } + return result, model.ErrCacheKeyDontExist + } + + contents, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + return result, model.ErrCacheKeyDontExist + } + return + } + + return contents, nil +} + +func (c *FileCache) Set(key string, value any, opts ...model.CacheOption) error { + path := c.getPathForItem(key) + + metadata := model.CacheItem{ + Key: key, + } + + for _, opt := range opts { + opt(&metadata) + } + + if err := c.setMetadata(key, metadata); err != nil { + return fmt.Errorf("error setting metadata: %s", err) + } + + if err := os.WriteFile(path, value.([]byte), 0766); err != nil { + return fmt.Errorf("error writting cache file: %s", err) + } + + return nil +} + +func (c *FileCache) Delete(key string) error { + path := c.getPathForItem(key) + + if err := os.Remove(path); err != nil { + return fmt.Errorf("error deleting cache file: %s", err) + } + + if err := os.Remove(path + ".metadata"); err != nil { + return fmt.Errorf("error deleting cache metadata: %s", err) + } + + return nil +} + +func (c *FileCache) getPathForItem(key string) string { + return filepath.Join(c.path, key) +} + +func (c *FileCache) getMetadata(key string) (model.CacheItem, error) { + path := c.getPathForItem(key) + ".metadata" + + contents, err := os.ReadFile(path) + if err != nil { + return model.CacheItem{}, fmt.Errorf("error reading cache file: %s", err) + } + + var metadata model.CacheItem + if err := json.Unmarshal(contents, &metadata); err != nil { + return model.CacheItem{}, fmt.Errorf("error unmarshalling cache file: %s", err) + } + + return metadata, nil +} + +func (c *FileCache) setMetadata(key string, metadata model.CacheItem) error { + path := c.getPathForItem(key) + ".metadata" + + contents, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("error marshalling cache file: %s", err) + } + + return os.WriteFile(path, contents, 0644) +} + +func NewFileCache(folderName string) (*FileCache, error) { + userCacheDir, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("Could not retrieve user cache directory: %w", err) + } + path := filepath.Join(userCacheDir, folderName) + + if err := os.MkdirAll(path, 0755); err != nil { + return nil, fmt.Errorf("couldn't create cache directory: %w", err) + } + + return &FileCache{ + path: path, + }, nil +} diff --git a/cache/file_test.go b/cache/file_test.go new file mode 100644 index 0000000..0889ef0 --- /dev/null +++ b/cache/file_test.go @@ -0,0 +1,412 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + "time" + + "git.nakama.town/fmartingr/gotoolkit/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFileCache(t *testing.T) { + // Create a test folder name + testFolderName := "test-cache" + + // Test initialization with the temp directory + cache, err := NewFileCache(testFolderName) + require.NoError(t, err, "NewFileCache should not return an error") + require.NotNil(t, cache, "NewFileCache should return a non-nil cache") + + // Verify the directory was created (not checking the exact path as it depends on the os.UserCacheDir output) + dirInfo, err := os.Stat(cache.path) + require.NoError(t, err, "Cache directory should exist") + assert.True(t, dirInfo.IsDir(), "Cache path should be a directory") +} + +func TestFileCache_Set(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + cache := setupTestFileCache(t, tempDir) + + // Test simple set operation + testKey := "test-key" + testValue := []byte("test-value") + err := cache.Set(testKey, testValue) + require.NoError(t, err, "Set should not return an error") + + // Verify the file exists + filePath := filepath.Join(cache.path, testKey) + assert.FileExists(t, filePath, "Cache file should exist") + assert.FileExists(t, filePath+".metadata", "Cache metadata file should exist") + + // Read the file contents + content, err := os.ReadFile(filePath) + require.NoError(t, err, "Should be able to read cache file") + assert.Equal(t, testValue, content, "File content should match set value") + + // Test set with TTL + ttlKey := "ttl-key" + ttlValue := []byte("ttl-value") + ttlDuration := 1 * time.Hour + withTTL := func(item *model.CacheItem) { + expiry := time.Now().Add(ttlDuration) + item.TTL = &expiry + } + + err = cache.Set(ttlKey, ttlValue, withTTL) + require.NoError(t, err, "Set with TTL should not return an error") + + // Verify file exists + ttlFilePath := filepath.Join(cache.path, ttlKey) + assert.FileExists(t, ttlFilePath, "Cache file with TTL should exist") + assert.FileExists(t, ttlFilePath+".metadata", "Cache metadata file with TTL should exist") + + // Verify the metadata has TTL set + metadata, err := cache.getMetadata(ttlKey) + require.NoError(t, err, "Should be able to get metadata") + assert.NotNil(t, metadata.TTL, "TTL should be set in metadata") +} + +func TestFileCache_SetTableDriven(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + key string + value []byte + withTTL bool + ttlDelta time.Duration + }{ + { + name: "basic set without TTL", + key: "key1", + value: []byte("value1"), + }, + { + name: "set with future TTL", + key: "key2", + value: []byte("value2"), + withTTL: true, + ttlDelta: 1 * time.Hour, // Future time + }, + { + name: "set with past TTL", + key: "key3", + value: []byte("value3"), + withTTL: true, + ttlDelta: -1 * time.Hour, // Past time + }, + { + name: "set with empty value", + key: "key4", + value: []byte{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := setupTestFileCache(t, tempDir) + + var opts []model.CacheOption + if tt.withTTL { + opts = append(opts, func(item *model.CacheItem) { + expiry := time.Now().Add(tt.ttlDelta) + item.TTL = &expiry + }) + } + + err := cache.Set(tt.key, tt.value, opts...) + require.NoError(t, err, "Set should not return an error") + + // Verify file and metadata exist + filePath := filepath.Join(cache.path, tt.key) + assert.FileExists(t, filePath, "Cache file should exist") + assert.FileExists(t, filePath+".metadata", "Cache metadata file should exist") + + // Verify file content + content, err := os.ReadFile(filePath) + require.NoError(t, err, "Should be able to read cache file") + assert.Equal(t, tt.value, content, "File content should match set value") + + // Verify metadata + metadata, err := cache.getMetadata(tt.key) + require.NoError(t, err, "Should be able to get metadata") + assert.Equal(t, tt.key, metadata.Key, "Metadata key should match") + + if tt.withTTL { + assert.NotNil(t, metadata.TTL, "TTL should be set in metadata") + expectedTime := time.Now().Add(tt.ttlDelta) + assert.WithinDuration(t, expectedTime, *metadata.TTL, 2*time.Second, "TTL should be close to expected value") + } else { + assert.Nil(t, metadata.TTL, "TTL should not be set for items without TTL") + } + }) + } +} + +func TestFileCache_Get(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + cache := setupTestFileCache(t, tempDir) + + // Set up test data + validKey := "valid-key" + validValue := []byte("valid-value") + err := cache.Set(validKey, validValue) + require.NoError(t, err, "Set should not return an error for setup") + + // Test getting valid key + retrievedValue, err := cache.Get(validKey) + require.NoError(t, err, "Get should not return an error for valid key") + assert.Equal(t, validValue, retrievedValue, "Retrieved value should match original") + + // Test getting non-existent key + _, err = cache.Get("nonexistent-key") + assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Get should return ErrCacheKeyDontExist for non-existent key") + + // Test getting expired key + expiredKey := "expired-key" + expiredValue := []byte("expired-value") + expiredTTL := func(item *model.CacheItem) { + expiry := time.Now().Add(-1 * time.Minute) // Past time + item.TTL = &expiry + } + + err = cache.Set(expiredKey, expiredValue, expiredTTL) + require.NoError(t, err, "Set should not return an error for expired key setup") + + _, err = cache.Get(expiredKey) + assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Get should return ErrCacheKeyDontExist for expired key") + + // Verify the expired files are deleted + expiredFilePath := filepath.Join(cache.path, expiredKey) + expiredMetadataPath := expiredFilePath + ".metadata" + assert.NoFileExists(t, expiredFilePath, "Expired cache file should be deleted") + assert.NoFileExists(t, expiredMetadataPath, "Expired cache metadata file should be deleted") +} + +func TestFileCache_GetTableDriven(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + setupKeys map[string][]byte + setupTTLs map[string]time.Duration + getKey string + expectValue []byte + expectError error + checkFileDeleted bool + }{ + { + name: "get existing key", + setupKeys: map[string][]byte{ + "test-key": []byte("test-value"), + }, + getKey: "test-key", + expectValue: []byte("test-value"), + expectError: nil, + }, + { + name: "get non-existent key", + setupKeys: map[string][]byte{ + "test-key": []byte("test-value"), + }, + getKey: "nonexistent-key", + expectValue: nil, + expectError: model.ErrCacheKeyDontExist, + }, + { + name: "get expired key", + setupKeys: map[string][]byte{ + "expired-key": []byte("expired-value"), + }, + setupTTLs: map[string]time.Duration{ + "expired-key": -1 * time.Minute, // Past time + }, + getKey: "expired-key", + expectValue: nil, + expectError: model.ErrCacheKeyDontExist, + checkFileDeleted: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := setupTestFileCache(t, tempDir) + + // Setup test data + for key, value := range tt.setupKeys { + var opts []model.CacheOption + if ttl, ok := tt.setupTTLs[key]; ok { + opts = append(opts, func(item *model.CacheItem) { + expiry := time.Now().Add(ttl) + item.TTL = &expiry + }) + } + + err := cache.Set(key, value, opts...) + require.NoError(t, err, "Setup: Set should not return an error") + } + + // Test get + value, err := cache.Get(tt.getKey) + + if tt.expectError != nil { + assert.ErrorIs(t, err, tt.expectError) + assert.Nil(t, value) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectValue, value) + } + + // Check if expired files are deleted + if tt.checkFileDeleted { + filePath := filepath.Join(cache.path, tt.getKey) + metadataPath := filePath + ".metadata" + assert.NoFileExists(t, filePath, "Expired cache file should be deleted") + assert.NoFileExists(t, metadataPath, "Expired cache metadata file should be deleted") + } + }) + } +} + +func TestFileCache_Delete(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + cache := setupTestFileCache(t, tempDir) + + // Set up test data + testKey := "delete-key" + testValue := []byte("delete-value") + err := cache.Set(testKey, testValue) + require.NoError(t, err, "Set should not return an error for setup") + + // Verify files exist before delete + filePath := filepath.Join(cache.path, testKey) + metadataPath := filePath + ".metadata" + assert.FileExists(t, filePath, "Cache file should exist before delete") + assert.FileExists(t, metadataPath, "Cache metadata file should exist before delete") + + // Test delete + err = cache.Delete(testKey) + require.NoError(t, err, "Delete should not return an error") + + // Verify files are deleted + assert.NoFileExists(t, filePath, "Cache file should be deleted") + assert.NoFileExists(t, metadataPath, "Cache metadata file should be deleted") + + // Test deleting non-existent key + err = cache.Delete("nonexistent-key") + assert.Error(t, err, "Delete should return an error for non-existent key") +} + +func TestFileCache_DeleteTableDriven(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + setupKey string + setupValue []byte + deleteKey string + expectError bool + checkNotExist bool + }{ + { + name: "delete existing key", + setupKey: "existing-key", + setupValue: []byte("existing-value"), + deleteKey: "existing-key", + expectError: false, + checkNotExist: true, + }, + { + name: "delete non-existent key", + deleteKey: "nonexistent-key", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := setupTestFileCache(t, tempDir) + + // Setup test data if needed + if tt.setupKey != "" { + err := cache.Set(tt.setupKey, tt.setupValue) + require.NoError(t, err, "Setup: Set should not return an error") + + // Verify files exist before delete + filePath := filepath.Join(cache.path, tt.setupKey) + metadataPath := filePath + ".metadata" + assert.FileExists(t, filePath, "Cache file should exist before delete") + assert.FileExists(t, metadataPath, "Cache metadata file should exist before delete") + } + + // Test delete + err := cache.Delete(tt.deleteKey) + + if tt.expectError { + assert.Error(t, err, "Delete should return an error") + } else { + assert.NoError(t, err, "Delete should not return an error") + } + + // Check files are deleted if needed + if tt.checkNotExist { + filePath := filepath.Join(cache.path, tt.deleteKey) + metadataPath := filePath + ".metadata" + assert.NoFileExists(t, filePath, "Cache file should be deleted") + assert.NoFileExists(t, metadataPath, "Cache metadata file should be deleted") + } + }) + } +} + +func TestFileCache_GetPathForItem(t *testing.T) { + tempDir := t.TempDir() + cache := setupTestFileCache(t, tempDir) + + key := "test-key" + expectedPath := filepath.Join(cache.path, key) + actualPath := cache.getPathForItem(key) + + assert.Equal(t, expectedPath, actualPath, "Path for item should match expected format") +} + +func TestFileCache_GetSetMetadata(t *testing.T) { + tempDir := t.TempDir() + cache := setupTestFileCache(t, tempDir) + + // Create test metadata + key := "metadata-key" + now := time.Now() + metadata := model.CacheItem{ + Key: key, + Value: nil, + TTL: &now, + } + + // Test setMetadata + err := cache.setMetadata(key, metadata) + require.NoError(t, err, "setMetadata should not return an error") + + // Verify metadata file exists + metadataPath := filepath.Join(cache.path, key) + ".metadata" + assert.FileExists(t, metadataPath, "Metadata file should exist") + + // Test getMetadata + retrievedMetadata, err := cache.getMetadata(key) + require.NoError(t, err, "getMetadata should not return an error") + assert.Equal(t, key, retrievedMetadata.Key, "Retrieved metadata key should match original") + assert.WithinDuration(t, now, *retrievedMetadata.TTL, time.Second, "Retrieved metadata TTL should be close to original") +} + +// Helper function to create a FileCache for testing +func setupTestFileCache(t *testing.T, tempDir string) *FileCache { + return &FileCache{ + path: tempDir, + } +} diff --git a/cache/memory.go b/cache/memory.go new file mode 100644 index 0000000..81c990e --- /dev/null +++ b/cache/memory.go @@ -0,0 +1,68 @@ +package cache + +import ( + "sync" + "time" + + "git.nakama.town/fmartingr/gotoolkit/model" +) + +var _ model.Cache = (*MemoryCache)(nil) + +type MemoryCache struct { + data map[string]model.CacheItem + dataMu sync.RWMutex +} + +func (c *MemoryCache) Get(key string) (result any, err error) { + c.dataMu.RLock() + + item, exists := c.data[key] + if !exists { + c.dataMu.RUnlock() + return result, model.ErrCacheKeyDontExist + } + + // Check expiration while still holding the lock + if item.TTL != nil && item.TTL.Before(time.Now()) { + c.dataMu.RUnlock() + _ = c.Delete(key) + return result, model.ErrCacheKeyDontExist + } + + value := item.Value + c.dataMu.RUnlock() + + return value, nil +} + +func (c *MemoryCache) Set(key string, value any, opts ...model.CacheOption) error { + item := model.CacheItem{ + Key: key, + Value: value, + } + + for _, opt := range opts { + opt(&item) + } + + c.dataMu.Lock() + c.data[key] = item + c.dataMu.Unlock() + + return nil +} + +func (c *MemoryCache) Delete(key string) error { + c.dataMu.Lock() + delete(c.data, key) + c.dataMu.Unlock() + return nil +} + +func NewMemoryCache() *MemoryCache { + return &MemoryCache{ + data: make(map[string]model.CacheItem), + dataMu: sync.RWMutex{}, + } +} diff --git a/cache/memory_test.go b/cache/memory_test.go new file mode 100644 index 0000000..0f09110 --- /dev/null +++ b/cache/memory_test.go @@ -0,0 +1,285 @@ +package cache + +import ( + "sync" + "testing" + "time" + + "git.nakama.town/fmartingr/gotoolkit/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMemoryCache(t *testing.T) { + cache := NewMemoryCache() + require.NotNil(t, cache, "NewMemoryCache returned nil") + require.NotNil(t, cache.data, "Cache data map was not initialized") +} + +func TestMemoryCache_Set(t *testing.T) { + cache := NewMemoryCache() + + // Test basic set + err := cache.Set("key1", "value1") + require.NoError(t, err, "Set returned unexpected error") + + // Verify the item was stored correctly + cache.dataMu.RLock() + item, exists := cache.data["key1"] + cache.dataMu.RUnlock() + require.True(t, exists, "Set did not store the item in the cache") + assert.Equal(t, "key1", item.Key, "Wrong key stored") + assert.Equal(t, "value1", item.Value, "Wrong value stored") + assert.Nil(t, item.TTL, "TTL should be nil") + + // Test set with TTL + withTTL := func(item *model.CacheItem) { + expiry := time.Now().Add(10 * time.Minute) + item.TTL = &expiry + } + + err = cache.Set("key2", "value2", withTTL) + require.NoError(t, err, "Set with TTL returned unexpected error") + + // Verify the item with TTL was stored correctly + cache.dataMu.RLock() + itemWithTTL, exists := cache.data["key2"] + cache.dataMu.RUnlock() + require.True(t, exists, "Set did not store the item with TTL in the cache") + assert.NotNil(t, itemWithTTL.TTL, "TTL option was not applied") +} + +func TestMemoryCache_SetTableDriven(t *testing.T) { + tests := []struct { + name string + key string + value interface{} + ttl *time.Duration + }{ + { + name: "string value without TTL", + key: "string-key", + value: "string-value", + ttl: nil, + }, + { + name: "int value without TTL", + key: "int-key", + value: 42, + ttl: nil, + }, + { + name: "struct value without TTL", + key: "struct-key", + value: struct{ Name string }{"test"}, + ttl: nil, + }, + { + name: "string value with TTL", + key: "string-key-ttl", + value: "string-value-ttl", + ttl: ptrDuration(5 * time.Minute), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := NewMemoryCache() + + var opts []model.CacheOption + if tt.ttl != nil { + opts = append(opts, func(item *model.CacheItem) { + expiry := time.Now().Add(*tt.ttl) + item.TTL = &expiry + }) + } + + err := cache.Set(tt.key, tt.value, opts...) + assert.NoError(t, err) + + cache.dataMu.RLock() + item, exists := cache.data[tt.key] + cache.dataMu.RUnlock() + + assert.True(t, exists) + assert.Equal(t, tt.key, item.Key) + assert.Equal(t, tt.value, item.Value) + + if tt.ttl != nil { + assert.NotNil(t, item.TTL) + } else { + assert.Nil(t, item.TTL) + } + }) + } +} + +func TestMemoryCache_Get(t *testing.T) { + cache := NewMemoryCache() + + // Test getting non-existent key + _, err := cache.Get("nonexistent") + assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Wrong error returned for non-existent key") + + // Test getting existing key + require.NoError(t, cache.Set("key1", "value1")) + value, err := cache.Get("key1") + require.NoError(t, err, "Get returned unexpected error") + assert.Equal(t, "value1", value, "Wrong value returned") + + // Test getting expired key + expiredTTL := func(item *model.CacheItem) { + expiry := time.Now().Add(-1 * time.Minute) // Past time + item.TTL = &expiry + } + require.NoError(t, cache.Set("expired", "value", expiredTTL)) + + _, err = cache.Get("expired") + assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Wrong error returned for expired item") + + // Verify the expired key was deleted + cache.dataMu.RLock() + _, exists := cache.data["expired"] + cache.dataMu.RUnlock() + assert.False(t, exists, "Expired item was not deleted during Get") +} + +func TestMemoryCache_GetTableDriven(t *testing.T) { + tests := []struct { + name string + setupKey string + setupValue any + setupTTL *time.Duration + getKey string + expectValue any + expectError error + }{ + { + name: "get existing key", + setupKey: "test-key", + setupValue: "test-value", + getKey: "test-key", + expectValue: "test-value", + expectError: nil, + }, + { + name: "get non-existent key", + setupKey: "test-key", + setupValue: "test-value", + getKey: "different-key", + expectValue: nil, + expectError: model.ErrCacheKeyDontExist, + }, + { + name: "get expired key", + setupKey: "expired-key", + setupValue: "expired-value", + setupTTL: ptrDuration(-1 * time.Minute), // Negative to create an expired key + getKey: "expired-key", + expectValue: nil, + expectError: model.ErrCacheKeyDontExist, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := NewMemoryCache() + + // Setup + var opts []model.CacheOption + if tt.setupTTL != nil { + opts = append(opts, func(item *model.CacheItem) { + expiry := time.Now().Add(*tt.setupTTL) + item.TTL = &expiry + }) + } + + if tt.setupKey != "" { + err := cache.Set(tt.setupKey, tt.setupValue, opts...) + require.NoError(t, err) + } + + // Test + value, err := cache.Get(tt.getKey) + + if tt.expectError != nil { + assert.ErrorIs(t, err, tt.expectError) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.expectValue, value) + + // If it was an expired key, ensure it's deleted + if tt.setupTTL != nil && *tt.setupTTL < 0 { + cache.dataMu.RLock() + _, exists := cache.data[tt.setupKey] + cache.dataMu.RUnlock() + assert.False(t, exists, "Expired item was not deleted during Get") + } + }) + } +} + +func TestMemoryCache_Delete(t *testing.T) { + cache := NewMemoryCache() + + // Setup test data + require.NoError(t, cache.Set("key1", "value1")) + + // Test deleting existing key + err := cache.Delete("key1") + require.NoError(t, err, "Delete returned unexpected error") + + // Verify the key was deleted + cache.dataMu.RLock() + _, exists := cache.data["key1"] + cache.dataMu.RUnlock() + assert.False(t, exists, "Item was not deleted from cache") + + // Test deleting non-existent key (should not error) + err = cache.Delete("nonexistent") + assert.NoError(t, err, "Delete on non-existent key returned error") +} + +func TestMemoryCache_Concurrency(t *testing.T) { + cache := NewMemoryCache() + const goroutines = 10 + const operations = 100 + + wg := sync.WaitGroup{} + wg.Add(goroutines * 2) + + // Writers + for i := range goroutines { + go func(id int) { + for j := range operations { + key := "key" + string(rune('A'+id)) + string(rune('0'+j%10)) + err := cache.Set(key, j) + assert.NoError(t, err) + } + wg.Done() + }(i) + } + + // Readers + for i := range goroutines { + go func(id int) { + for j := range operations { + key := "key" + string(rune('A'+id)) + string(rune('0'+j%10)) + _, _ = cache.Get(key) // We ignore errors here as keys might not exist yet + } + wg.Done() + }(i) + } + + // Wait for all goroutines to complete + wg.Wait() + + // If we reached here without deadlock or panic, the test passes +} + +// Helper function to create duration pointers +func ptrDuration(d time.Duration) *time.Duration { + return &d +} diff --git a/cache/options.go b/cache/options.go new file mode 100644 index 0000000..6497b83 --- /dev/null +++ b/cache/options.go @@ -0,0 +1,14 @@ +package cache + +import ( + "time" + + "git.nakama.town/fmartingr/gotoolkit/model" +) + +func WithTTL(ttl time.Duration) model.CacheOption { + return func(item *model.CacheItem) { + exp := time.Now().Add(ttl) + item.TTL = &exp + } +} diff --git a/go.mod b/go.mod index 372d4c0..3314212 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,21 @@ go 1.23.3 require ( github.com/pelletier/go-toml/v2 v2.2.3 + github.com/stretchr/testify v1.9.0 modernc.org/sqlite v1.34.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index 17e4d56..4550c4b 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= diff --git a/model/cache.go b/model/cache.go new file mode 100644 index 0000000..4b58326 --- /dev/null +++ b/model/cache.go @@ -0,0 +1,22 @@ +package model + +import ( + "errors" + "time" +) + +var ErrCacheKeyDontExist = errors.New("cache key don't exist") + +type Cache interface { + Get(key string) (any, error) + Set(key string, value any, opts ...CacheOption) error + Delete(key string) error +} + +type CacheItem struct { + Key string + Value any + TTL *time.Time +} + +type CacheOption func(item *CacheItem) diff --git a/paths/user.go b/paths/user.go new file mode 100644 index 0000000..0810c9d --- /dev/null +++ b/paths/user.go @@ -0,0 +1,20 @@ +package paths + +import ( + "os/user" + "path/filepath" + "strings" +) + +func ExpandUser(providedPath string) string { + var path string = providedPath + usr, _ := user.Current() + dir := usr.HomeDir + + if providedPath == "~" { + path = dir + } else if strings.HasPrefix(providedPath, "~/") { + path = filepath.Join(dir, providedPath[2:]) + } + return path +} diff --git a/template/engine.go b/template/engine.go index 53414d3..2e1e3ac 100644 --- a/template/engine.go +++ b/template/engine.go @@ -2,9 +2,9 @@ package template import ( "bytes" - "embed" "fmt" "html/template" + "io/fs" ) // Engine is a template engine @@ -24,8 +24,8 @@ func (e *Engine) Render(name string, data any) ([]byte, error) { } // NewTemplateEngine creates a new template engine from the given templates -func NewEngine(templates embed.FS) (*Engine, error) { - tmpls, err := template.ParseFS(templates, "**/*.html") +func NewEngine(templates fs.FS) (*Engine, error) { + tmpls, err := template.ParseFS(templates, "*.html") if err != nil { return nil, fmt.Errorf("failed to parse templates: %w", err) }