diff --git a/go.mod b/go.mod index 7afdbc1..70ee512 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/shirou/gopsutil/v3 v3.24.5 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 6b6644d..a2b6b44 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 3a6d345..255e6cb 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -48,12 +48,19 @@ type Watcher struct { Compress bool `yaml:"compress"` } +type Cache struct { + Enabled bool `yaml:"enabled"` + DefaultTTL string `yaml:"default_ttl"` + CleanupInterval string `yaml:"cleanup_interval"` +} + type ConfigData struct { Netbridge Netbridge `yaml:"netbridge"` Arrows Arrows `yaml:"arrows"` API API `yaml:"api"` Database Database `yaml:"database"` Watcher Watcher `yaml:"watcher"` + Cache Cache `yaml:"cache"` } type Config struct { @@ -99,6 +106,10 @@ func GetWatcher() Watcher { return Get().Config.Watcher } +func GetCache() Cache { + return Get().Config.Cache +} + func GetConfigPath() string { return metadata.GetDefaultConfigPath() } @@ -143,6 +154,11 @@ func getDefaultConfig() *Config { MaxAge: 7, Compress: true, }, + Cache: Cache{ + Enabled: false, + DefaultTTL: "5m", + CleanupInterval: "1m", + }, }, } } diff --git a/internal/core/database/builder.go b/internal/core/database/builder.go new file mode 100644 index 0000000..7155ce1 --- /dev/null +++ b/internal/core/database/builder.go @@ -0,0 +1,49 @@ +package database + +import ( + "context" + + "github.com/rabbytesoftware/quiver/internal/core/database/cache" + interfaces "github.com/rabbytesoftware/quiver/internal/core/database/interface" + "github.com/rabbytesoftware/quiver/internal/core/database/repository" + + dberr "github.com/rabbytesoftware/quiver/internal/core/database/error" +) + +type DatabaseBuilder[T any] struct { + name string + cacheConfig *cache.CacheConfig + extractKey func(entity *T) (string, error) +} + +func NewDatabaseBuilder[T any]( + ctx context.Context, + name string, +) *DatabaseBuilder[T] { + return &DatabaseBuilder[T]{ + name: name, + } +} + +func (b *DatabaseBuilder[T]) WithCache(config cache.CacheConfig, extractKey func(entity *T) (string, error)) *DatabaseBuilder[T] { + b.cacheConfig = &config + b.extractKey = extractKey + return b +} + +func (b *DatabaseBuilder[T]) Build() (interfaces.RepositoryInterface[T], error) { + if b.name == "" { + return nil, dberr.ErrNameRequired + } + + repo, err := repository.NewRepository[T](b.name) + if err != nil { + return nil, err + } + + if b.cacheConfig != nil && b.cacheConfig.IsValid() { + return cache.NewCachedRepository[T](repo, *b.cacheConfig, b.extractKey) + } + + return repo, nil +} diff --git a/internal/core/database/builder_test.go b/internal/core/database/builder_test.go new file mode 100644 index 0000000..372a72a --- /dev/null +++ b/internal/core/database/builder_test.go @@ -0,0 +1,202 @@ +package database + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/rabbytesoftware/quiver/internal/core/database/cache" + interfaces "github.com/rabbytesoftware/quiver/internal/core/database/interface" +) + +type BuilderTestEntity struct { + ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"` + Name string `gorm:"not null" json:"name"` +} + +func (BuilderTestEntity) TableName() string { + return "builder_test_entities" +} + +// builderTestEntityExtractKey extracts the ID as a string for cache keys +func builderTestEntityExtractKey(entity *BuilderTestEntity) (string, error) { + if entity == nil { + return "", nil + } + return entity.ID.String(), nil +} + +func TestDatabaseBuilder_Build_WithoutCache(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + db, err := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_no_cache"). + Build() + + require.NoError(t, err) + assert.NotNil(t, db) + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestDatabaseBuilder_Build_WithCache(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + cacheConfig := cache.CacheConfig{ + DefaultTTL: 5 * time.Minute, + CleanupInterval: 1 * time.Minute, + } + + db, err := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_with_cache"). + WithCache(cacheConfig, builderTestEntityExtractKey). + Build() + + require.NoError(t, err) + assert.NotNil(t, db) + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestDatabaseBuilder_Build_WithDefaultCacheConfig(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + cacheConfig := cache.DefaultCacheConfig() + + db, err := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_default_cache"). + WithCache(cacheConfig, builderTestEntityExtractKey). + Build() + + require.NoError(t, err) + assert.NotNil(t, db) + + entity := &BuilderTestEntity{ID: uuid.New(), Name: "Test"} + created, err := db.Create(ctx, entity) + require.NoError(t, err) + + retrieved, err := db.GetByID(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.Name, retrieved.Name) + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestDatabaseBuilder_Build_InvalidCacheConfig_FallsBackToNonCached(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + cacheConfig := cache.CacheConfig{ + DefaultTTL: 0, + CleanupInterval: time.Minute, + } + + db, err := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_invalid_cache"). + WithCache(cacheConfig, builderTestEntityExtractKey). + Build() + + require.NoError(t, err) + assert.NotNil(t, db) + + entity := &BuilderTestEntity{ID: uuid.New(), Name: "Test"} + created, err := db.Create(ctx, entity) + require.NoError(t, err) + + retrieved, err := db.GetByID(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.Name, retrieved.Name) + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestDatabaseBuilder_Chaining(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + cacheConfig := cache.CacheConfig{ + DefaultTTL: 5 * time.Minute, + CleanupInterval: 1 * time.Minute, + } + + builder := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_chaining") + builder = builder.WithCache(cacheConfig, builderTestEntityExtractKey) + db, err := builder.Build() + + require.NoError(t, err) + assert.NotNil(t, db) + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestDatabaseBuilder_Build_ReturnsRepositoryInterface(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + db, err := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_interface").Build() + + require.NoError(t, err) + + var _ interfaces.RepositoryInterface[BuilderTestEntity] = db + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestNewDatabase_StillWorks(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + db, err := NewDatabase[BuilderTestEntity](ctx, "test_backwards_compat") + + require.NoError(t, err) + assert.NotNil(t, db) + + t.Cleanup(func() { + _ = db.Close() + }) +} + +func TestDatabaseBuilder_MultipleBuilds(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + builder := NewDatabaseBuilder[BuilderTestEntity](ctx, "test_multiple") + + db1, err1 := builder.Build() + db2, err2 := builder.Build() + + require.NoError(t, err1) + require.NoError(t, err2) + assert.NotNil(t, db1) + assert.NotNil(t, db2) + + assert.NotEqual(t, db1, db2) + + t.Cleanup(func() { + _ = db1.Close() + _ = db2.Close() + }) +} diff --git a/internal/core/database/cache/cached_repository.go b/internal/core/database/cache/cached_repository.go new file mode 100644 index 0000000..a225038 --- /dev/null +++ b/internal/core/database/cache/cached_repository.go @@ -0,0 +1,196 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "github.com/patrickmn/go-cache" + cacheerr "github.com/rabbytesoftware/quiver/internal/core/database/cache/error" + interfaces "github.com/rabbytesoftware/quiver/internal/core/database/interface" + "github.com/rabbytesoftware/quiver/internal/core/watcher" +) + +type CachedRepository[T any] struct { + db interfaces.RepositoryInterface[T] + cache *cache.Cache + config CacheConfig + ExtractKey func(entity *T) (string, error) +} + +func NewCachedRepository[T any]( + baseRepo interfaces.RepositoryInterface[T], + config CacheConfig, + ExtractKey func(entity *T) (string, error), +) (interfaces.RepositoryInterface[T], error) { + + if baseRepo == nil { + return nil, cacheerr.ErrMissingBase + } + + if config.DefaultTTL <= 0 || config.CleanupInterval <= 0 { + return nil, cacheerr.ErrInvalidCacheConfig + } + + return &CachedRepository[T]{ + db: baseRepo, + cache: cache.New(config.DefaultTTL, config.CleanupInterval), + config: config, + ExtractKey: ExtractKey, + }, nil +} + +func (cr *CachedRepository[T]) Create(ctx context.Context, entity *T) (*T, error) { + return cr.db.Create(ctx, entity) +} + +func (cr *CachedRepository[T]) Get(ctx context.Context) ([]*T, error) { + key := cr.buildListKey() + + v, found := cr.cache.Get(key) + + if found { + data, ok := v.([]byte) + if !ok { + return nil, cacheerr.ErrInvalidCacheValue + } + var result []*T + err := json.Unmarshal(data, &result) + if err != nil { + return nil, err + } + return result, nil + } + + result, err := cr.db.Get(ctx) + if err != nil { + return nil, err + } + + if err := cr.setList(key, result); err != nil { + watcher.Warn(fmt.Sprintf("%v: failed to cache list: %v", + cacheerr.ErrCacheWriteFailed, err)) + } + + return result, nil +} + +func (cr *CachedRepository[T]) GetByID(ctx context.Context, id uuid.UUID) (*T, error) { + key := cr.buildEntityKey(id.String()) + v, found := cr.cache.Get(key) + if found { + data, ok := v.([]byte) + if !ok { + return nil, cacheerr.ErrInvalidCacheValue + } + var result *T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + return result, nil + } + + entity, err := cr.db.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if err := cr.set(key, entity); err != nil { + watcher.Warn(fmt.Sprintf("%v: failed to cache entity %s: %v", + cacheerr.ErrCacheWriteFailed, id, err)) + } + + return entity, nil +} + +func (cr *CachedRepository[T]) Update(ctx context.Context, entity *T) (*T, error) { + result, err := cr.db.Update(ctx, entity) + if err != nil { + return nil, err + } + + id, err := cr.ExtractKey(result) + if err != nil { + return result, cacheerr.ErrIDExtractionFailed + } + + key := cr.buildEntityKey(id) + cr.cache.Delete(key) + + return result, nil +} + +func (cr *CachedRepository[T]) Delete(ctx context.Context, id uuid.UUID) error { + if err := cr.db.Delete(ctx, id); err != nil { + return err + } + + key := cr.buildEntityKey(id.String()) + cr.cache.Delete(key) + + return nil +} + +func (cr *CachedRepository[T]) Exists(ctx context.Context, id uuid.UUID) (bool, error) { + if _, found := cr.cache.Get(cr.buildEntityKey(id.String())); found { + return true, nil + } + + return cr.db.Exists(ctx, id) +} + +func (cr *CachedRepository[T]) Count(ctx context.Context) (int64, error) { + if count, found := cr.cache.Get(cr.buildListKey()); found { + return count.(int64), nil + } + + return cr.db.Count(ctx) +} + +func (cr *CachedRepository[T]) Where( + ctx context.Context, + query string, + args ...interface{}, +) ([]*T, error) { + return cr.db.Where(ctx, query, args...) +} + +func (cr *CachedRepository[T]) Close() error { + return cr.db.Close() +} + +func (cr *CachedRepository[T]) buildEntityKey(id string) string { + return fmt.Sprintf("entity:%s:%s", cr.getEntityTypeName(), id) +} + +func (cr *CachedRepository[T]) buildListKey() string { + return fmt.Sprintf("list:%s", cr.getEntityTypeName()) +} + +func (cr *CachedRepository[T]) getEntityTypeName() string { + var zero T + return fmt.Sprintf("%T", zero) +} + +func (cr *CachedRepository[T]) set(key string, data *T) error { + value, err := json.Marshal(data) + if err != nil { + return err + } + + cr.cache.Set(key, value, cr.config.DefaultTTL) + return nil +} + +func (cr *CachedRepository[T]) setList(key string, data []*T) error { + value, err := json.Marshal(data) + if err != nil { + return err + } + + cr.cache.Set(key, value, cr.config.DefaultTTL) + return nil +} diff --git a/internal/core/database/cache/cached_repository_test.go b/internal/core/database/cache/cached_repository_test.go new file mode 100644 index 0000000..0cf00ac --- /dev/null +++ b/internal/core/database/cache/cached_repository_test.go @@ -0,0 +1,1408 @@ +package cache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cacheerr "github.com/rabbytesoftware/quiver/internal/core/database/cache/error" + interfaces "github.com/rabbytesoftware/quiver/internal/core/database/interface" + "github.com/rabbytesoftware/quiver/internal/core/database/repository" +) + +type TestEntity struct { + ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"` + Name string `gorm:"not null" json:"name"` + Age int `json:"age"` +} + +func (TestEntity) TableName() string { + return "cache_test_entities" +} + +// ExtractKey functions for different entity types +func testEntityExtractKey(entity *TestEntity) (string, error) { + if entity == nil { + return "", errors.New("nil entity") + } + return entity.ID.String(), nil +} + +func entityWithoutIDExtractKey(entity *EntityWithoutID) (string, error) { + return "", errors.New("entity has no ID field") +} + +func entityWithWrongIDTypeExtractKey(entity *EntityWithWrongIDType) (string, error) { + if entity == nil { + return "", errors.New("nil entity") + } + return entity.ID, nil +} + +func TestNewCachedRepository_ValidConfig(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + config := DefaultCacheConfig() + dbName := fmt.Sprintf("valid_config_test_%d", time.Now().UnixNano()) + + baseRepo, err := repository.NewRepository[TestEntity](dbName) + require.NoError(t, err) + + result, err := NewCachedRepository[TestEntity](baseRepo, config, testEntityExtractKey) + + require.NoError(t, err) + assert.NotNil(t, result, "Should return CachedRepository") + + cachedRepo, ok := result.(*CachedRepository[TestEntity]) + require.True(t, ok, "Result should be *CachedRepository") + assert.NotNil(t, cachedRepo.cache) + assert.NotNil(t, cachedRepo.db) + assert.Equal(t, config, cachedRepo.config) + + t.Cleanup(func() { _ = result.Close() }) +} + +func TestNewCachedRepository_NilBaseRepo(t *testing.T) { + config := DefaultCacheConfig() + + result, err := NewCachedRepository[TestEntity](nil, config, testEntityExtractKey) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, cacheerr.ErrMissingBase) +} + +func TestNewCachedRepository_InvalidTTL(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + dbName := fmt.Sprintf("invalid_ttl_test_%d", time.Now().UnixNano()) + baseRepo, err := repository.NewRepository[TestEntity](dbName) + require.NoError(t, err) + t.Cleanup(func() { _ = baseRepo.Close() }) + + config := CacheConfig{ + DefaultTTL: 0, // Invalid: must be > 0 + CleanupInterval: 10 * time.Minute, + } + + result, err := NewCachedRepository[TestEntity](baseRepo, config, testEntityExtractKey) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, cacheerr.ErrInvalidCacheConfig) +} + +func TestNewCachedRepository_InvalidCleanupInterval(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + dbName := fmt.Sprintf("invalid_cleanup_test_%d", time.Now().UnixNano()) + baseRepo, err := repository.NewRepository[TestEntity](dbName) + require.NoError(t, err) + t.Cleanup(func() { _ = baseRepo.Close() }) + + config := CacheConfig{ + DefaultTTL: 5 * time.Minute, + CleanupInterval: -1 * time.Second, // Invalid: must be > 0 + } + + result, err := NewCachedRepository[TestEntity](baseRepo, config, testEntityExtractKey) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, cacheerr.ErrInvalidCacheConfig) +} + +func TestCachedRepository_Create(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Test Entity", + Age: 25, + } + + created, err := cachedRepo.Create(ctx, entity) + + require.NoError(t, err) + assert.Equal(t, entity.ID, created.ID) + assert.Equal(t, entity.Name, created.Name) +} + +func TestCachedRepository_GetByID_CacheMiss(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "Test Entity", Age: 30} + _, err := cachedRepo.Create(ctx, entity) + require.NoError(t, err) + + result, err := cachedRepo.GetByID(ctx, entity.ID) + + require.NoError(t, err) + assert.Equal(t, entity.ID, result.ID) + assert.Equal(t, entity.Name, result.Name) +} + +func TestCachedRepository_GetByID_CacheHit(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "Test Entity", Age: 30} + _, err := cachedRepo.Create(ctx, entity) + require.NoError(t, err) + + _, err = cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + + result, err := cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + assert.Equal(t, entity.ID, result.ID) + assert.Equal(t, entity.Name, result.Name) +} + +func TestCachedRepository_GetByID_NotFound(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + _, err := cachedRepo.GetByID(ctx, uuid.New()) + + assert.Error(t, err) +} + +func TestCachedRepository_Get_CachesIndividually(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entities := []*TestEntity{ + {ID: uuid.New(), Name: "Entity 1", Age: 25}, + {ID: uuid.New(), Name: "Entity 2", Age: 30}, + {ID: uuid.New(), Name: "Entity 3", Age: 35}, + } + for _, e := range entities { + _, err := cachedRepo.Create(ctx, e) + require.NoError(t, err) + } + + result, err := cachedRepo.Get(ctx) + require.NoError(t, err) + assert.Len(t, result, 3) + + for _, entity := range entities { + retrieved, err := cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + assert.Equal(t, entity.Name, retrieved.Name) + } +} + +func TestCachedRepository_Update_InvalidatesCache(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "Original", Age: 25} + _, err := cachedRepo.Create(ctx, entity) + require.NoError(t, err) + + _, err = cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + + entity.Name = "Updated" + _, err = cachedRepo.Update(ctx, entity) + require.NoError(t, err) + + retrieved, err := cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + assert.Equal(t, "Updated", retrieved.Name) +} + +func TestCachedRepository_Delete_InvalidatesCache(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "ToDelete", Age: 25} + _, err := cachedRepo.Create(ctx, entity) + require.NoError(t, err) + + _, err = cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + + err = cachedRepo.Delete(ctx, entity.ID) + require.NoError(t, err) + + _, err = cachedRepo.GetByID(ctx, entity.ID) + assert.Error(t, err, "Should not find deleted entity") +} + +func TestCachedRepository_Exists(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "Test"} + _, err := cachedRepo.Create(ctx, entity) + require.NoError(t, err) + + exists, err := cachedRepo.Exists(ctx, entity.ID) + require.NoError(t, err) + assert.True(t, exists) + + exists, err = cachedRepo.Exists(ctx, uuid.New()) + require.NoError(t, err) + assert.False(t, exists) +} + +func TestCachedRepository_Count(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + _, err := cachedRepo.Create(ctx, &TestEntity{ID: uuid.New(), Name: "One", Age: 25}) + require.NoError(t, err) + _, err = cachedRepo.Create(ctx, &TestEntity{ID: uuid.New(), Name: "Two", Age: 30}) + require.NoError(t, err) + + count, err := cachedRepo.Count(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), count) +} + +func TestCachedRepository_Integration_CRUD(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Integration Test", + Age: 30, + } + created, err := cachedRepo.Create(ctx, entity) + require.NoError(t, err) + assert.Equal(t, entity.ID, created.ID) + + retrieved, err := cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + assert.Equal(t, entity.Name, retrieved.Name) + + entity.Name = "Updated Name" + updated, err := cachedRepo.Update(ctx, entity) + require.NoError(t, err) + assert.Equal(t, "Updated Name", updated.Name) + + retrieved, err = cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + assert.Equal(t, "Updated Name", retrieved.Name) + + err = cachedRepo.Delete(ctx, entity.ID) + require.NoError(t, err) + + exists, err := cachedRepo.Exists(ctx, entity.ID) + require.NoError(t, err) + assert.False(t, exists) +} + +func TestCachedRepository_Integration_CacheExpiry(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + config := CacheConfig{ + DefaultTTL: 100 * time.Millisecond, + CleanupInterval: 50 * time.Millisecond, + } + dbName := fmt.Sprintf("cache_expiry_test_%d", time.Now().UnixNano()) + + baseRepo, err := repository.NewRepository[TestEntity](dbName) + require.NoError(t, err) + + cachedRepo, err := NewCachedRepository[TestEntity](baseRepo, config, testEntityExtractKey) + require.NoError(t, err) + t.Cleanup(func() { _ = cachedRepo.Close() }) + + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "Expiry Test", Age: 25} + _, err = cachedRepo.Create(ctx, entity) + require.NoError(t, err) + + _, err = cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + retrieved, err := cachedRepo.GetByID(ctx, entity.ID) + require.NoError(t, err) + assert.Equal(t, entity.Name, retrieved.Name) +} + +func TestCachedRepository_Integration_ConcurrentAccess(t *testing.T) { + cachedRepo := setupCachedRepo(t) + ctx := context.Background() + + const numGoroutines = 10 + var wg sync.WaitGroup + errors := make(chan error, numGoroutines*3) + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + entity := &TestEntity{ + ID: uuid.New(), + Name: fmt.Sprintf("Concurrent %d", i), + Age: 20 + i, + } + _, err := cachedRepo.Create(ctx, entity) + if err != nil { + errors <- err + } + }(i) + } + + wg.Wait() + close(errors) + + for err := range errors { + t.Errorf("Concurrent operation failed: %v", err) + } + + count, err := cachedRepo.Count(ctx) + require.NoError(t, err) + assert.Equal(t, int64(numGoroutines), count) +} + +func TestParity_Create(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + entity := &TestEntity{ID: uuid.New(), Name: "Parity Test", Age: 25} + + baseResult, baseErr := baseRepo.Create(ctx, entity) + + entity2 := &TestEntity{ID: uuid.New(), Name: "Parity Test", Age: 25} + cachedResult, cachedErr := cachedRepo.Create(ctx, entity2) + + assert.Equal(t, baseErr == nil, cachedErr == nil, "Error status should match") + assert.Equal(t, baseResult.Name, cachedResult.Name) + assert.Equal(t, baseResult.Age, cachedResult.Age) +} + +func TestParity_GetByID(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + baseID := uuid.New() + cachedID := uuid.New() + baseEntity := &TestEntity{ID: baseID, Name: "Parity Test", Age: 30} + cachedEntity := &TestEntity{ID: cachedID, Name: "Parity Test", Age: 30} + + _, err := baseRepo.Create(ctx, baseEntity) + require.NoError(t, err) + _, err = cachedRepo.Create(ctx, cachedEntity) + require.NoError(t, err) + + baseResult, baseErr := baseRepo.GetByID(ctx, baseID) + cachedResult, cachedErr := cachedRepo.GetByID(ctx, cachedID) + + require.NoError(t, baseErr) + require.NoError(t, cachedErr) + assert.Equal(t, baseResult.Name, cachedResult.Name) + assert.Equal(t, baseResult.Age, cachedResult.Age) +} + +func TestParity_Get(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + for i := 0; i < 2; i++ { + baseEntity := &TestEntity{ID: uuid.New(), Name: fmt.Sprintf("Entity %d", i), Age: 25 + i} + cachedEntity := &TestEntity{ID: uuid.New(), Name: fmt.Sprintf("Entity %d", i), Age: 25 + i} + _, err := baseRepo.Create(ctx, baseEntity) + require.NoError(t, err) + _, err = cachedRepo.Create(ctx, cachedEntity) + require.NoError(t, err) + } + + baseResult, baseErr := baseRepo.Get(ctx) + cachedResult, cachedErr := cachedRepo.Get(ctx) + + require.NoError(t, baseErr) + require.NoError(t, cachedErr) + assert.Len(t, cachedResult, len(baseResult)) +} + +func TestParity_Update(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + baseID := uuid.New() + cachedID := uuid.New() + baseEntity := &TestEntity{ID: baseID, Name: "Original", Age: 25} + cachedEntity := &TestEntity{ID: cachedID, Name: "Original", Age: 25} + + _, err := baseRepo.Create(ctx, baseEntity) + require.NoError(t, err) + _, err = cachedRepo.Create(ctx, cachedEntity) + require.NoError(t, err) + + baseEntity.Name = "Updated" + cachedEntity.Name = "Updated" + baseResult, baseErr := baseRepo.Update(ctx, baseEntity) + cachedResult, cachedErr := cachedRepo.Update(ctx, cachedEntity) + + require.NoError(t, baseErr) + require.NoError(t, cachedErr) + assert.Equal(t, baseResult.Name, cachedResult.Name) +} + +func TestParity_Exists(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + baseID := uuid.New() + cachedID := uuid.New() + + baseExists, _ := baseRepo.Exists(ctx, baseID) + cachedExists, _ := cachedRepo.Exists(ctx, cachedID) + assert.Equal(t, baseExists, cachedExists) + + baseEntity := &TestEntity{ID: baseID, Name: "Test", Age: 25} + cachedEntity := &TestEntity{ID: cachedID, Name: "Test", Age: 25} + _, _ = baseRepo.Create(ctx, baseEntity) + _, _ = cachedRepo.Create(ctx, cachedEntity) + + baseExists, _ = baseRepo.Exists(ctx, baseID) + cachedExists, _ = cachedRepo.Exists(ctx, cachedID) + assert.Equal(t, baseExists, cachedExists) +} + +func TestParity_Count(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + baseCount, _ := baseRepo.Count(ctx) + cachedCount, _ := cachedRepo.Count(ctx) + assert.Equal(t, baseCount, cachedCount) + + for i := 0; i < 3; i++ { + baseEntity := &TestEntity{ID: uuid.New(), Name: fmt.Sprintf("Entity %d", i), Age: 20 + i} + cachedEntity := &TestEntity{ID: uuid.New(), Name: fmt.Sprintf("Entity %d", i), Age: 20 + i} + _, _ = baseRepo.Create(ctx, baseEntity) + _, _ = cachedRepo.Create(ctx, cachedEntity) + } + + baseCount, _ = baseRepo.Count(ctx) + cachedCount, _ = cachedRepo.Count(ctx) + assert.Equal(t, baseCount, cachedCount) +} + +// MockRepository for testing error paths +type MockRepository[T any] struct { + GetFunc func(ctx context.Context) ([]*T, error) + GetByIDFunc func(ctx context.Context, id uuid.UUID) (*T, error) + CreateFunc func(ctx context.Context, entity *T) (*T, error) + UpdateFunc func(ctx context.Context, entity *T) (*T, error) + DeleteFunc func(ctx context.Context, id uuid.UUID) error + ExistsFunc func(ctx context.Context, id uuid.UUID) (bool, error) + CountFunc func(ctx context.Context) (int64, error) + WhereFunc func(ctx context.Context, query string, args ...interface{}) ([]*T, error) + CloseFunc func() error +} + +func (m *MockRepository[T]) Get(ctx context.Context) ([]*T, error) { + if m.GetFunc != nil { + return m.GetFunc(ctx) + } + return nil, nil +} + +func (m *MockRepository[T]) GetByID(ctx context.Context, id uuid.UUID) (*T, error) { + if m.GetByIDFunc != nil { + return m.GetByIDFunc(ctx, id) + } + return nil, nil +} + +func (m *MockRepository[T]) Create(ctx context.Context, entity *T) (*T, error) { + if m.CreateFunc != nil { + return m.CreateFunc(ctx, entity) + } + return entity, nil +} + +func (m *MockRepository[T]) Update(ctx context.Context, entity *T) (*T, error) { + if m.UpdateFunc != nil { + return m.UpdateFunc(ctx, entity) + } + return entity, nil +} + +func (m *MockRepository[T]) Delete(ctx context.Context, id uuid.UUID) error { + if m.DeleteFunc != nil { + return m.DeleteFunc(ctx, id) + } + return nil +} + +func (m *MockRepository[T]) Exists(ctx context.Context, id uuid.UUID) (bool, error) { + if m.ExistsFunc != nil { + return m.ExistsFunc(ctx, id) + } + return false, nil +} + +func (m *MockRepository[T]) Count(ctx context.Context) (int64, error) { + if m.CountFunc != nil { + return m.CountFunc(ctx) + } + return 0, nil +} + +func (m *MockRepository[T]) Where(ctx context.Context, query string, args ...interface{}) ([]*T, error) { + if m.WhereFunc != nil { + return m.WhereFunc(ctx, query, args...) + } + return nil, nil +} + +func (m *MockRepository[T]) Close() error { + if m.CloseFunc != nil { + return m.CloseFunc() + } + return nil +} + +func TestCachedRepository_Get_CacheHit(t *testing.T) { + // Test that Get returns cached data when available + mockRepo := &MockRepository[TestEntity]{ + GetFunc: func(ctx context.Context) ([]*TestEntity, error) { + // This should NOT be called if cache hit + return nil, errors.New("should not reach database") + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + + // Pre-populate cache with valid list data + cachedEntities := []*TestEntity{ + {ID: uuid.New(), Name: "Cached1", Age: 25}, + {ID: uuid.New(), Name: "Cached2", Age: 30}, + } + listJSON, err := json.Marshal(cachedEntities) + require.NoError(t, err) + cr.cache.Set("list:cache.TestEntity", listJSON, config.DefaultTTL) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "Cached1", result[0].Name) + assert.Equal(t, "Cached2", result[1].Name) +} + +func TestCachedRepository_Get_InvalidCacheValue(t *testing.T) { + mockRepo := &MockRepository[TestEntity]{ + GetFunc: func(ctx context.Context) ([]*TestEntity, error) { + return []*TestEntity{}, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + // Inject invalid value type into cache (not []byte) + cr.cache.Set("list:cache.TestEntity", "invalid-string-not-bytes", config.DefaultTTL) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, cacheerr.ErrInvalidCacheValue) +} + +func TestCachedRepository_Get_UnmarshalError(t *testing.T) { + mockRepo := &MockRepository[TestEntity]{ + GetFunc: func(ctx context.Context) ([]*TestEntity, error) { + return []*TestEntity{}, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + // Inject invalid JSON bytes into cache + cr.cache.Set("list:cache.TestEntity", []byte("not-valid-json{{{"), config.DefaultTTL) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestCachedRepository_Get_DatabaseError(t *testing.T) { + dbError := errors.New("database connection failed") + mockRepo := &MockRepository[TestEntity]{ + GetFunc: func(ctx context.Context) ([]*TestEntity, error) { + return nil, dbError + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, dbError) +} + +func TestCachedRepository_GetByID_InvalidCacheValue(t *testing.T) { + mockRepo := &MockRepository[TestEntity]{} + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + testID := uuid.New() + // Inject invalid value type into cache (not []byte) + key := fmt.Sprintf("entity:cache.TestEntity:%s", testID.String()) + cr.cache.Set(key, 12345, config.DefaultTTL) // integer instead of []byte + + ctx := context.Background() + result, err := cachedRepo.GetByID(ctx, testID) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, cacheerr.ErrInvalidCacheValue) +} + +func TestCachedRepository_GetByID_UnmarshalError(t *testing.T) { + mockRepo := &MockRepository[TestEntity]{} + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + testID := uuid.New() + // Inject invalid JSON bytes into cache + key := fmt.Sprintf("entity:cache.TestEntity:%s", testID.String()) + cr.cache.Set(key, []byte("{invalid json"), config.DefaultTTL) + + ctx := context.Background() + result, err := cachedRepo.GetByID(ctx, testID) + + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestCachedRepository_Update_DatabaseError(t *testing.T) { + dbError := errors.New("database update failed") + mockRepo := &MockRepository[TestEntity]{ + UpdateFunc: func(ctx context.Context, entity *TestEntity) (*TestEntity, error) { + return nil, dbError + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + entity := &TestEntity{ID: uuid.New(), Name: "Test", Age: 25} + + result, err := cachedRepo.Update(ctx, entity) + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, dbError) +} + +// EntityWithoutID is used to test ID extraction failure +type EntityWithoutID struct { + Name string `json:"name"` + Age int `json:"age"` +} + +func TestCachedRepository_Update_IDExtractionFailed(t *testing.T) { + mockRepo := &MockRepository[EntityWithoutID]{ + UpdateFunc: func(ctx context.Context, entity *EntityWithoutID) (*EntityWithoutID, error) { + return entity, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[EntityWithoutID](mockRepo, config, entityWithoutIDExtractKey) + require.NoError(t, err) + + ctx := context.Background() + entity := &EntityWithoutID{Name: "Test", Age: 25} + + result, err := cachedRepo.Update(ctx, entity) + + // Update succeeds but returns error due to ID extraction failure + assert.NotNil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, cacheerr.ErrIDExtractionFailed) +} + +func TestCachedRepository_Delete_DatabaseError(t *testing.T) { + dbError := errors.New("database delete failed") + mockRepo := &MockRepository[TestEntity]{ + DeleteFunc: func(ctx context.Context, id uuid.UUID) error { + return dbError + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + err = cachedRepo.Delete(ctx, uuid.New()) + + assert.Error(t, err) + assert.ErrorIs(t, err, dbError) +} + +func TestCachedRepository_Count_CacheHit(t *testing.T) { + mockRepo := &MockRepository[TestEntity]{ + CountFunc: func(ctx context.Context) (int64, error) { + return 999, nil // This should NOT be called if cache hit + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + // Pre-populate cache with count + cr.cache.Set("list:cache.TestEntity", int64(42), config.DefaultTTL) + + ctx := context.Background() + count, err := cachedRepo.Count(ctx) + + require.NoError(t, err) + assert.Equal(t, int64(42), count) +} + +func TestCachedRepository_Where_Success(t *testing.T) { + expectedResults := []*TestEntity{ + {ID: uuid.New(), Name: "Match 1", Age: 25}, + {ID: uuid.New(), Name: "Match 2", Age: 30}, + } + + mockRepo := &MockRepository[TestEntity]{ + WhereFunc: func(ctx context.Context, query string, args ...interface{}) ([]*TestEntity, error) { + return expectedResults, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.Where(ctx, "age > ?", 20) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, expectedResults[0].Name, result[0].Name) + assert.Equal(t, expectedResults[1].Name, result[1].Name) +} + +func TestCachedRepository_Where_Error(t *testing.T) { + dbError := errors.New("where query failed") + mockRepo := &MockRepository[TestEntity]{ + WhereFunc: func(ctx context.Context, query string, args ...interface{}) ([]*TestEntity, error) { + return nil, dbError + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.Where(ctx, "invalid query") + + assert.Nil(t, result) + assert.Error(t, err) + assert.ErrorIs(t, err, dbError) +} + +func TestParity_Where(t *testing.T) { + baseRepo, cachedRepo := setupParityRepos(t) + ctx := context.Background() + + // Create some entities + for i := 0; i < 5; i++ { + baseEntity := &TestEntity{ID: uuid.New(), Name: fmt.Sprintf("Entity %d", i), Age: 20 + i} + cachedEntity := &TestEntity{ID: uuid.New(), Name: fmt.Sprintf("Entity %d", i), Age: 20 + i} + _, err := baseRepo.Create(ctx, baseEntity) + require.NoError(t, err) + _, err = cachedRepo.Create(ctx, cachedEntity) + require.NoError(t, err) + } + + // Query with Where + baseResult, baseErr := baseRepo.Where(ctx, "age >= ?", 22) + cachedResult, cachedErr := cachedRepo.Where(ctx, "age >= ?", 22) + + // Both should succeed or fail together + assert.Equal(t, baseErr == nil, cachedErr == nil, "Error status should match") + if baseErr == nil && cachedErr == nil { + assert.Equal(t, len(baseResult), len(cachedResult), "Result count should match") + } +} + +// EntityWithWrongIDType has an ID field but it's not uuid.UUID +type EntityWithWrongIDType struct { + ID string `json:"id"` // Wrong type: string instead of uuid.UUID + Name string `json:"name"` +} + +func TestExtractID_NilEntity(t *testing.T) { + // When Get returns entities including nil, extractID should handle it gracefully + mockRepo := &MockRepository[TestEntity]{ + GetFunc: func(ctx context.Context) ([]*TestEntity, error) { + // Return a slice with a nil entity + return []*TestEntity{nil, {ID: uuid.New(), Name: "Valid"}}, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + // Should not panic, but nil entity won't be cached + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestExtractID_MissingIDField(t *testing.T) { + // Test with entity that has no ID field - ID extraction should fail gracefully + mockRepo := &MockRepository[EntityWithoutID]{ + GetFunc: func(ctx context.Context) ([]*EntityWithoutID, error) { + return []*EntityWithoutID{ + {Name: "Entity1", Age: 25}, + {Name: "Entity2", Age: 30}, + }, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[EntityWithoutID](mockRepo, config, entityWithoutIDExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + // Should succeed but entities won't be individually cached + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestExtractID_WrongIDType(t *testing.T) { + // Test with entity that has ID field but wrong type + mockRepo := &MockRepository[EntityWithWrongIDType]{ + GetFunc: func(ctx context.Context) ([]*EntityWithWrongIDType, error) { + return []*EntityWithWrongIDType{ + {ID: "string-id-1", Name: "Entity1"}, + {ID: "string-id-2", Name: "Entity2"}, + }, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[EntityWithWrongIDType](mockRepo, config, entityWithWrongIDTypeExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.Get(ctx) + + // Should succeed but entities won't be individually cached (ID extraction fails) + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestExtractID_GetByID_EntityWithoutID(t *testing.T) { + // Test GetByID with entity that doesn't have proper ID + testEntity := &EntityWithoutID{Name: "NoID", Age: 25} + mockRepo := &MockRepository[EntityWithoutID]{ + GetByIDFunc: func(ctx context.Context, id uuid.UUID) (*EntityWithoutID, error) { + return testEntity, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[EntityWithoutID](mockRepo, config, entityWithoutIDExtractKey) + require.NoError(t, err) + + ctx := context.Background() + result, err := cachedRepo.GetByID(ctx, uuid.New()) + + // Should succeed but entity won't be cached + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "NoID", result.Name) +} + +func TestCachedRepository_Exists_CacheHit(t *testing.T) { + // Test that Exists returns true when entity is in cache + mockRepo := &MockRepository[TestEntity]{ + ExistsFunc: func(ctx context.Context, id uuid.UUID) (bool, error) { + // This should NOT be called if cache hit + return false, errors.New("should not reach database") + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + cr := cachedRepo.(*CachedRepository[TestEntity]) + testID := uuid.New() + testEntity := &TestEntity{ID: testID, Name: "Cached", Age: 30} + + // Pre-populate cache with the entity (simulating a prior GetByID call) + entityJSON, err := json.Marshal(testEntity) + require.NoError(t, err) + key := fmt.Sprintf("entity:cache.TestEntity:%s", testID.String()) + cr.cache.Set(key, entityJSON, config.DefaultTTL) + + ctx := context.Background() + exists, err := cachedRepo.Exists(ctx, testID) + + require.NoError(t, err) + assert.True(t, exists, "Exists should return true when entity is in cache") +} + +func TestCachedRepository_Exists_CacheMiss_DBReturnsTrue(t *testing.T) { + // Test that Exists delegates to DB when cache miss, DB returns true + testID := uuid.New() + mockRepo := &MockRepository[TestEntity]{ + ExistsFunc: func(ctx context.Context, id uuid.UUID) (bool, error) { + if id == testID { + return true, nil + } + return false, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + exists, err := cachedRepo.Exists(ctx, testID) + + require.NoError(t, err) + assert.True(t, exists) +} + +func TestCachedRepository_Exists_CacheMiss_DBReturnsFalse(t *testing.T) { + // Test that Exists delegates to DB when cache miss, DB returns false + mockRepo := &MockRepository[TestEntity]{ + ExistsFunc: func(ctx context.Context, id uuid.UUID) (bool, error) { + return false, nil + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + exists, err := cachedRepo.Exists(ctx, uuid.New()) + + require.NoError(t, err) + assert.False(t, exists) +} + +func TestCachedRepository_Exists_DatabaseError(t *testing.T) { + // Test that Exists propagates database errors + dbError := errors.New("database error in exists") + mockRepo := &MockRepository[TestEntity]{ + ExistsFunc: func(ctx context.Context, id uuid.UUID) (bool, error) { + return false, dbError + }, + } + + config := DefaultCacheConfig() + cachedRepo, err := NewCachedRepository[TestEntity](mockRepo, config, testEntityExtractKey) + require.NoError(t, err) + + ctx := context.Background() + exists, err := cachedRepo.Exists(ctx, uuid.New()) + + assert.False(t, exists) + assert.Error(t, err) + assert.ErrorIs(t, err, dbError) +} + +func setupCachedRepo(t *testing.T) interfaces.RepositoryInterface[TestEntity] { + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + config := DefaultCacheConfig() + dbName := fmt.Sprintf("cached_repo_test_%d", time.Now().UnixNano()) + + baseRepo, err := repository.NewRepository[TestEntity](dbName) + require.NoError(t, err) + + cachedRepo, err := NewCachedRepository[TestEntity](baseRepo, config, testEntityExtractKey) + require.NoError(t, err) + + t.Cleanup(func() { _ = cachedRepo.Close() }) + + return cachedRepo +} + +func setupParityRepos(t *testing.T) ( + interfaces.RepositoryInterface[TestEntity], + interfaces.RepositoryInterface[TestEntity], +) { + tempDir := t.TempDir() + t.Setenv("QUIVER_DATABASE_PATH", tempDir) + + baseDbName := fmt.Sprintf("parity_base_%d", time.Now().UnixNano()) + baseRepo, err := repository.NewRepository[TestEntity](baseDbName) + require.NoError(t, err) + t.Cleanup(func() { _ = baseRepo.Close() }) + + cachedDbName := fmt.Sprintf("parity_cached_%d", time.Now().UnixNano()) + config := DefaultCacheConfig() + + cachedBaseRepo, err := repository.NewRepository[TestEntity](cachedDbName) + require.NoError(t, err) + + cachedRepo, err := NewCachedRepository[TestEntity](cachedBaseRepo, config, testEntityExtractKey) + require.NoError(t, err) + t.Cleanup(func() { _ = cachedRepo.Close() }) + + return baseRepo, cachedRepo +} + +func BenchmarkCachedRepository_Create(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + entity := &TestEntity{ + ID: uuid.New(), + Name: fmt.Sprintf("Benchmark Entity %d", i), + Age: 25 + (i % 50), + } + _, _ = repo.Create(ctx, entity) + } +} + +func BenchmarkCachedRepository_GetByID_CacheMiss(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + ids := make([]uuid.UUID, b.N) + for i := 0; i < b.N; i++ { + entity := &TestEntity{ + ID: uuid.New(), + Name: fmt.Sprintf("Entity %d", i), + Age: 25, + } + created, _ := repo.Create(ctx, entity) + ids[i] = created.ID + } + + cachedRepo := repo.(*CachedRepository[TestEntity]) + cachedRepo.cache.Flush() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.GetByID(ctx, ids[i%len(ids)]) + } +} + +func BenchmarkCachedRepository_GetByID_CacheHit(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Cached Entity", + Age: 30, + } + created, _ := repo.Create(ctx, entity) + + _, _ = repo.GetByID(ctx, created.ID) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.GetByID(ctx, created.ID) + } +} + +func BenchmarkCachedRepository_Get(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + for i := 0; i < 100; i++ { + entity := &TestEntity{ + ID: uuid.New(), + Name: fmt.Sprintf("Entity %d", i), + Age: 20 + i, + } + _, _ = repo.Create(ctx, entity) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.Get(ctx) + } +} + +func BenchmarkCachedRepository_Update(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Original", + Age: 25, + } + created, _ := repo.Create(ctx, entity) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + created.Name = fmt.Sprintf("Updated %d", i) + _, _ = repo.Update(ctx, created) + } +} + +func BenchmarkCachedRepository_Delete(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + ids := make([]uuid.UUID, b.N) + for i := 0; i < b.N; i++ { + entity := &TestEntity{ + ID: uuid.New(), + Name: fmt.Sprintf("ToDelete %d", i), + Age: 25, + } + created, _ := repo.Create(ctx, entity) + ids[i] = created.ID + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = repo.Delete(ctx, ids[i]) + } +} + +func BenchmarkCachedRepository_Exists_CacheHit(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Exists Test", + Age: 30, + } + created, _ := repo.Create(ctx, entity) + + _, _ = repo.GetByID(ctx, created.ID) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.Exists(ctx, created.ID) + } +} + +func BenchmarkCachedRepository_Count(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + for i := 0; i < 50; i++ { + entity := &TestEntity{ + ID: uuid.New(), + Name: fmt.Sprintf("Entity %d", i), + Age: 20 + i, + } + _, _ = repo.Create(ctx, entity) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.Count(ctx) + } +} + +func BenchmarkComparison_GetByID_NonCached(b *testing.B) { + repo, cleanup := setupBenchmarkBaseRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Test Entity", + Age: 30, + } + created, _ := repo.Create(ctx, entity) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.GetByID(ctx, created.ID) + } +} + +func BenchmarkComparison_GetByID_Cached(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Test Entity", + Age: 30, + } + created, _ := repo.Create(ctx, entity) + + _, _ = repo.GetByID(ctx, created.ID) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.GetByID(ctx, created.ID) + } +} + +func BenchmarkComparison_Exists_NonCached(b *testing.B) { + repo, cleanup := setupBenchmarkBaseRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Test Entity", + Age: 30, + } + created, _ := repo.Create(ctx, entity) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.Exists(ctx, created.ID) + } +} + +func BenchmarkComparison_Exists_Cached(b *testing.B) { + repo, cleanup := setupBenchmarkRepo(b) + defer cleanup() + + ctx := context.Background() + + entity := &TestEntity{ + ID: uuid.New(), + Name: "Test Entity", + Age: 30, + } + created, _ := repo.Create(ctx, entity) + + _, _ = repo.GetByID(ctx, created.ID) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = repo.Exists(ctx, created.ID) + } +} + +func setupBenchmarkRepo(b *testing.B) (interfaces.RepositoryInterface[TestEntity], func()) { + b.Helper() + + tempDir := b.TempDir() + b.Setenv("QUIVER_DATABASE_PATH", tempDir) + + config := DefaultCacheConfig() + dbName := fmt.Sprintf("bench_cached_%d", time.Now().UnixNano()) + + baseRepo, err := repository.NewRepository[TestEntity](dbName) + if err != nil { + b.Fatalf("Failed to create base repository: %v", err) + } + + cachedRepo, err := NewCachedRepository[TestEntity](baseRepo, config, testEntityExtractKey) + if err != nil { + _ = baseRepo.Close() + b.Fatalf("Failed to create cached repository: %v", err) + } + + cleanup := func() { + _ = cachedRepo.Close() + } + + return cachedRepo, cleanup +} + +func setupBenchmarkBaseRepo(b *testing.B) (interfaces.RepositoryInterface[TestEntity], func()) { + b.Helper() + + tempDir := b.TempDir() + b.Setenv("QUIVER_DATABASE_PATH", tempDir) + + dbName := fmt.Sprintf("bench_base_%d", time.Now().UnixNano()) + + baseRepo, err := repository.NewRepository[TestEntity](dbName) + if err != nil { + b.Fatalf("Failed to create base repository: %v", err) + } + + cleanup := func() { + _ = baseRepo.Close() + } + + return baseRepo, cleanup +} diff --git a/internal/core/database/cache/config.go b/internal/core/database/cache/config.go new file mode 100644 index 0000000..557895d --- /dev/null +++ b/internal/core/database/cache/config.go @@ -0,0 +1,26 @@ +package cache + +import ( + "time" +) + +const ( + defaultTTL = 5 * time.Minute + defaultCleanupInterval = 1 * time.Minute +) + +type CacheConfig struct { + DefaultTTL time.Duration `yaml:"default_ttl"` + CleanupInterval time.Duration `yaml:"cleanup_interval"` +} + +func DefaultCacheConfig() CacheConfig { + return CacheConfig{ + DefaultTTL: defaultTTL, + CleanupInterval: defaultCleanupInterval, + } +} + +func (c CacheConfig) IsValid() bool { + return (c.DefaultTTL > 0) && (c.CleanupInterval > 0) +} diff --git a/internal/core/database/cache/error/errors.go b/internal/core/database/cache/error/errors.go new file mode 100644 index 0000000..e92e025 --- /dev/null +++ b/internal/core/database/cache/error/errors.go @@ -0,0 +1,12 @@ +package error + +import "errors" + +var ( + ErrInvalidCacheConfig = errors.New("INVALID_CACHE_CONFIGURATION") + ErrIDExtractionFailed = errors.New("ID_EXTRACTION_FAILED") + ErrMissingCache = errors.New("MISSING_CACHE") + ErrMissingBase = errors.New("MISSING_BASE") + ErrInvalidCacheValue = errors.New("INVALID_CACHE_VALUE") + ErrCacheWriteFailed = errors.New("CACHE_WRITE_FAILED") +) diff --git a/internal/core/database/database_test.go b/internal/core/database/database_test.go index 3b33037..96c461c 100644 --- a/internal/core/database/database_test.go +++ b/internal/core/database/database_test.go @@ -13,7 +13,6 @@ type TestStruct struct { func TestNewDatabase(t *testing.T) { ctx := context.Background() - // Test creating a new database db, err := NewDatabase[TestStruct](ctx, "test_database") if err != nil { t.Fatalf("NewDatabase() failed: %v", err) @@ -33,7 +32,6 @@ func TestNewDatabase(t *testing.T) { func TestNewDatabase_WithDifferentTypes(t *testing.T) { ctx := context.Background() - // Test with TestStruct type which is safe db, err := NewDatabase[TestStruct](ctx, "test_struct_db") if err != nil { t.Logf("NewDatabase[TestStruct]() error (may be expected): %v", err) @@ -47,7 +45,6 @@ func TestNewDatabase_WithDifferentTypes(t *testing.T) { } func TestNewDatabase_WithDifferentContexts(t *testing.T) { - // Test with different contexts type contextKey string tests := []struct { name string @@ -79,7 +76,6 @@ func TestNewDatabase_WithDifferentContexts(t *testing.T) { func TestNewDatabase_WithDifferentNames(t *testing.T) { ctx := context.Background() - // Test with different database names names := []string{ "test_db", "another_db", @@ -109,11 +105,8 @@ func TestNewDatabase_WithDifferentNames(t *testing.T) { func TestNewDatabase_ErrorHandling(t *testing.T) { ctx := context.Background() - // Test that NewDatabase handles errors appropriately db, err := NewDatabase[TestStruct](ctx, "test_error_db") - // The error handling depends on the repository implementation - // We just verify the function executes without panicking _ = err t.Cleanup(func() { diff --git a/internal/core/database/error/errors.go b/internal/core/database/error/errors.go new file mode 100644 index 0000000..fcaadd3 --- /dev/null +++ b/internal/core/database/error/errors.go @@ -0,0 +1,7 @@ +package error + +import "errors" + +var ( + ErrNameRequired = errors.New("NAME_IS_REQUIRED") +) diff --git a/internal/core/database/repository/repository_test.go b/internal/core/database/repository/repository_test.go index 94a9e68..dd9bcb0 100644 --- a/internal/core/database/repository/repository_test.go +++ b/internal/core/database/repository/repository_test.go @@ -12,23 +12,19 @@ import ( "github.com/stretchr/testify/require" ) -// TestEntity represents a test entity for repository testing type TestEntity struct { ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"` Name string `gorm:"not null" json:"name"` Age int `json:"age"` } -// TableName returns the table name for the TestEntity func (TestEntity) TableName() string { return "test_entities" } func TestNewRepository(t *testing.T) { - // Create a temporary directory for testing tempDir := t.TempDir() - // Override the database path for testing originalPath := os.Getenv("QUIVER_DATABASE_PATH") defer func() { if originalPath != "" { @@ -40,7 +36,6 @@ func TestNewRepository(t *testing.T) { os.Setenv("QUIVER_DATABASE_PATH", tempDir) - // Test repository creation repo, err := NewRepository[TestEntity]("test") require.NoError(t, err) assert.NotNil(t, repo) @@ -49,7 +44,6 @@ func TestNewRepository(t *testing.T) { _ = repo.Close() }) - // Verify the repository has the correct name repoImpl := repo.(*Repository[TestEntity]) assert.Equal(t, "test", repoImpl.name) assert.NotNil(t, repoImpl.db) @@ -59,7 +53,6 @@ func TestRepository_Create(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Test creating a new entity entity := &TestEntity{ ID: uuid.New(), Name: "Test Entity", @@ -78,7 +71,6 @@ func TestRepository_GetByID(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Create an entity first entity := &TestEntity{ ID: uuid.New(), Name: "Test Entity", @@ -87,14 +79,12 @@ func TestRepository_GetByID(t *testing.T) { created, err := repo.Create(ctx, entity) require.NoError(t, err) - // Test getting by ID retrieved, err := repo.GetByID(ctx, created.ID) require.NoError(t, err) assert.Equal(t, created.ID, retrieved.ID) assert.Equal(t, created.Name, retrieved.Name) assert.Equal(t, created.Age, retrieved.Age) - // Test getting non-existent entity nonExistentID := uuid.New() _, err = repo.GetByID(ctx, nonExistentID) assert.Error(t, err) @@ -105,7 +95,6 @@ func TestRepository_Get(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Create multiple entities entities := []*TestEntity{ {ID: uuid.New(), Name: "Entity 1", Age: 25}, {ID: uuid.New(), Name: "Entity 2", Age: 30}, @@ -117,12 +106,10 @@ func TestRepository_Get(t *testing.T) { require.NoError(t, err) } - // Test getting all entities allEntities, err := repo.Get(ctx) require.NoError(t, err) assert.Len(t, allEntities, 3) - // Verify all entities are present names := make(map[string]bool) for _, entity := range allEntities { names[entity.Name] = true @@ -136,7 +123,6 @@ func TestRepository_Update(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Create an entity entity := &TestEntity{ ID: uuid.New(), Name: "Original Name", @@ -145,7 +131,6 @@ func TestRepository_Update(t *testing.T) { created, err := repo.Create(ctx, entity) require.NoError(t, err) - // Update the entity created.Name = "Updated Name" created.Age = 30 @@ -155,7 +140,6 @@ func TestRepository_Update(t *testing.T) { assert.Equal(t, 30, updated.Age) assert.Equal(t, created.ID, updated.ID) - // Verify the update persisted retrieved, err := repo.GetByID(ctx, created.ID) require.NoError(t, err) assert.Equal(t, "Updated Name", retrieved.Name) @@ -166,7 +150,6 @@ func TestRepository_Delete(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Create an entity entity := &TestEntity{ ID: uuid.New(), Name: "To Be Deleted", @@ -175,21 +158,17 @@ func TestRepository_Delete(t *testing.T) { created, err := repo.Create(ctx, entity) require.NoError(t, err) - // Verify entity exists exists, err := repo.Exists(ctx, created.ID) require.NoError(t, err) assert.True(t, exists) - // Delete the entity err = repo.Delete(ctx, created.ID) require.NoError(t, err) - // Verify entity no longer exists exists, err = repo.Exists(ctx, created.ID) require.NoError(t, err) assert.False(t, exists) - // Verify GetByID returns error _, err = repo.GetByID(ctx, created.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "not found") @@ -199,13 +178,11 @@ func TestRepository_Exists(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Test non-existent entity nonExistentID := uuid.New() exists, err := repo.Exists(ctx, nonExistentID) require.NoError(t, err) assert.False(t, exists) - // Create an entity entity := &TestEntity{ ID: uuid.New(), Name: "Test Entity", @@ -214,7 +191,6 @@ func TestRepository_Exists(t *testing.T) { created, err := repo.Create(ctx, entity) require.NoError(t, err) - // Test existing entity exists, err = repo.Exists(ctx, created.ID) require.NoError(t, err) assert.True(t, exists) @@ -224,12 +200,10 @@ func TestRepository_Count(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Test empty repository count, err := repo.Count(ctx) require.NoError(t, err) assert.Equal(t, int64(0), count) - // Create multiple entities entities := []*TestEntity{ {ID: uuid.New(), Name: "Entity 1", Age: 25}, {ID: uuid.New(), Name: "Entity 2", Age: 30}, @@ -241,12 +215,10 @@ func TestRepository_Count(t *testing.T) { require.NoError(t, err) } - // Test count after creating entities count, err = repo.Count(ctx) require.NoError(t, err) assert.Equal(t, int64(3), count) - // Delete one entity firstEntity, err := repo.Get(ctx) require.NoError(t, err) require.Len(t, firstEntity, 3) @@ -254,7 +226,6 @@ func TestRepository_Count(t *testing.T) { err = repo.Delete(ctx, firstEntity[0].ID) require.NoError(t, err) - // Test count after deletion count, err = repo.Count(ctx) require.NoError(t, err) assert.Equal(t, int64(2), count) @@ -263,11 +234,9 @@ func TestRepository_Count(t *testing.T) { func TestRepository_ContextCancellation(t *testing.T) { repo := setupTestRepository(t) - // Create a cancelled context ctx, cancel := context.WithCancel(context.Background()) cancel() - // Test that operations respect context cancellation _, err := repo.Get(ctx) assert.Error(t, err) @@ -290,12 +259,9 @@ func TestRepository_ContextCancellation(t *testing.T) { assert.Error(t, err) } -// setupTestRepository creates a test repository with a temporary database func setupTestRepository(t *testing.T) *Repository[TestEntity] { - // Create a temporary directory for testing tempDir := t.TempDir() - // Override the database path for testing originalPath := os.Getenv("QUIVER_DATABASE_PATH") t.Cleanup(func() { if originalPath != "" { @@ -307,7 +273,6 @@ func setupTestRepository(t *testing.T) *Repository[TestEntity] { os.Setenv("QUIVER_DATABASE_PATH", tempDir) - // Create repository with unique name for each test uniqueName := fmt.Sprintf("test_%s_%d", t.Name(), time.Now().UnixNano()) repo, err := NewRepository[TestEntity](uniqueName) require.NoError(t, err) @@ -319,12 +284,10 @@ func setupTestRepository(t *testing.T) *Repository[TestEntity] { return repo.(*Repository[TestEntity]) } -// TestRepository_ConcurrentOperations tests concurrent access to the repository func TestRepository_ConcurrentOperations(t *testing.T) { repo := setupTestRepository(t) ctx := context.Background() - // Create multiple entities concurrently const numEntities = 10 done := make(chan error, numEntities) @@ -340,18 +303,15 @@ func TestRepository_ConcurrentOperations(t *testing.T) { }(i) } - // Wait for all operations to complete for i := 0; i < numEntities; i++ { err := <-done require.NoError(t, err) } - // Verify all entities were created count, err := repo.Count(ctx) require.NoError(t, err) assert.Equal(t, int64(numEntities), count) - // Verify we can retrieve all entities entities, err := repo.Get(ctx) require.NoError(t, err) assert.Len(t, entities, numEntities)