// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package infra_test import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/plugins/infra/cache" "Wavelet/plugins/infra/database" "Wavelet/plugins/infra/logger" "Wavelet/plugins/infra/storage" "Wavelet/plugins/infra/storage/objectstore" "bytes" "context" "io" "path/filepath" "sync" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) type TestUser struct { ID uint64 `gorm:"primaryKey"` Name string } func setupTestDB(t *testing.T) *gorm.DB { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&TestUser{})) return db } func TestDatabasePlugin(t *testing.T) { ctx := core.NewContext(context.Background()) testDB := setupTestDB(t) p := database.New(database.WithDB(testDB)) require.Equal(t, "database", p.Name()) require.NoError(t, p.Apply(ctx)) dbSvc, err := core.Inject[contracts.DBService](ctx) require.NoError(t, err) require.NotNil(t, dbSvc) // Verify GORM and DB methods assert.NotNil(t, dbSvc.GORM()) assert.NotNil(t, dbSvc.DB(context.Background())) // Test CRUD via service user := TestUser{ID: 1, Name: "Alice"} require.NoError(t, dbSvc.DB(context.Background()).Create(&user).Error) var fetched TestUser require.NoError(t, dbSvc.GORM().First(&fetched, 1).Error) assert.Equal(t, "Alice", fetched.Name) // Test NamedDB fallback assert.NotNil(t, dbSvc.Named("replica")) } func TestCachePluginRAMOnly(t *testing.T) { ctx := core.NewContext(context.Background()) p := cache.New() require.Equal(t, "cache", p.Name()) require.NoError(t, p.Apply(ctx)) cacheSvc, err := core.Inject[contracts.CacheService](ctx) require.NoError(t, err) require.NotNil(t, cacheSvc) type CacheItem struct { Name string `json:"name"` Count int `json:"count"` } testCtx := context.Background() // 1. Get non-existing key var notFound CacheItem err = cacheSvc.Get(testCtx, "missing:key", ¬Found) assert.ErrorIs(t, err, contracts.ErrCacheMiss) // 2. Set and Get item := CacheItem{Name: "item1", Count: 42} require.NoError(t, cacheSvc.Set(testCtx, "test:item1", item, time.Minute)) var retrieved CacheItem require.NoError(t, cacheSvc.Get(testCtx, "test:item1", &retrieved)) assert.Equal(t, item, retrieved) // 3. GetOrSet var getOrSetTarget CacheItem var loaderCalled bool err = cacheSvc.GetOrSet(testCtx, "test:item1", &getOrSetTarget, time.Minute, func() (any, error) { loaderCalled = true return CacheItem{Name: "never_called", Count: 0}, nil }) require.NoError(t, err) assert.False(t, loaderCalled) assert.Equal(t, item, getOrSetTarget) // GetOrSet with cache miss var newItem CacheItem err = cacheSvc.GetOrSet(testCtx, "test:item2", &newItem, time.Minute, func() (any, error) { loaderCalled = true return CacheItem{Name: "loaded", Count: 99}, nil }) require.NoError(t, err) assert.True(t, loaderCalled) assert.Equal(t, "loaded", newItem.Name) assert.Equal(t, 99, newItem.Count) // 4. Delete require.NoError(t, cacheSvc.Delete(testCtx, "test:item1")) err = cacheSvc.Get(testCtx, "test:item1", &retrieved) assert.ErrorIs(t, err, contracts.ErrCacheMiss) // 5. Invalidate alias require.NoError(t, cacheSvc.Invalidate(testCtx, "test:item2")) err = cacheSvc.Get(testCtx, "test:item2", &retrieved) assert.ErrorIs(t, err, contracts.ErrCacheMiss) } func TestCachePluginWithRedisAndPubSub(t *testing.T) { mr, err := miniredis.Run() require.NoError(t, err) defer mr.Close() rdb := redis.NewClient(&redis.Options{ Addr: mr.Addr(), }) defer func() { _ = rdb.Close() }() ctx1 := core.NewContext(context.Background()) ctx2 := core.NewContext(context.Background()) p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:")) p2 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:")) require.NoError(t, p1.Apply(ctx1)) require.NoError(t, p2.Apply(ctx2)) cache1, err := core.Inject[contracts.CacheService](ctx1) require.NoError(t, err) cache2, err := core.Inject[contracts.CacheService](ctx2) require.NoError(t, err) testCtx := context.Background() // Node 1 writes to cache type UserCache struct { Name string `json:"name"` } require.NoError(t, cache1.Set(testCtx, "user:100", UserCache{Name: "Bob"}, 10*time.Minute)) // Node 2 reads from cache (misses Node 2's RAM, hits Redis, backfills Node 2's RAM) var u2 UserCache require.NoError(t, cache2.Get(testCtx, "user:100", &u2)) assert.Equal(t, "Bob", u2.Name) // Node 1 deletes cache (evicts Node 1 RAM, Redis, and broadcasts to Node 2) require.NoError(t, cache1.Delete(testCtx, "user:100")) // Verify Redis is deleted var uRedis UserCache err = cache1.Get(testCtx, "user:100", &uRedis) assert.ErrorIs(t, err, contracts.ErrCacheMiss) // Clean up contexts require.NoError(t, ctx1.Dispose()) require.NoError(t, ctx2.Dispose()) } func TestLoggerPlugin(t *testing.T) { ctx := core.NewContext(context.Background()) p := logger.New() require.Equal(t, "logger", p.Name()) require.NoError(t, p.Apply(ctx)) logSvc, err := core.Inject[contracts.LoggerService](ctx) require.NoError(t, err) require.NotNil(t, logSvc) testCtx := context.Background() // Should not panic on any log call logSvc.Debug(testCtx, "debug message", "key1", "val1") logSvc.Info(testCtx, "info message", "userID", 123) logSvc.Warn(testCtx, "warn message", "warning", true) logSvc.Error(testCtx, "error message", "err", "something broke") logSvc.Debugf(testCtx, "formatted debug %d", 1) logSvc.Infof(testCtx, "formatted info %s", "test") logSvc.Warnf(testCtx, "formatted warn %v", map[string]int{"a": 1}) logSvc.Errorf(testCtx, "formatted error %s", "fatal") childLog := logSvc.With("module", "test_module") require.NotNil(t, childLog) childLog.Info(testCtx, "child log message", "action", "run") } type memoryBackend struct { mu sync.RWMutex storage map[string][]byte } func newMemoryBackend() *memoryBackend { return &memoryBackend{ storage: make(map[string][]byte), } } func (m *memoryBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (objectstore.PutResult, error) { m.mu.Lock() defer m.mu.Unlock() data, err := io.ReadAll(body) if err != nil { return objectstore.PutResult{}, err } m.storage[key] = data return objectstore.PutResult{Key: key, Bucket: "test-bucket"}, nil } func (m *memoryBackend) Get(ctx context.Context, key string) (*objectstore.Object, error) { m.mu.RLock() defer m.mu.RUnlock() data, ok := m.storage[key] if !ok { return nil, gorm.ErrRecordNotFound } return &objectstore.Object{ Body: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data)), ContentType: "application/octet-stream", }, nil } func (m *memoryBackend) Delete(ctx context.Context, key string) error { m.mu.Lock() defer m.mu.Unlock() delete(m.storage, key) return nil } func (m *memoryBackend) Test(ctx context.Context) error { return nil } func TestStoragePlugin(t *testing.T) { ctx := core.NewContext(context.Background()) backend := newMemoryBackend() p := storage.New(storage.WithBackend(backend)) require.Equal(t, "storage", p.Name()) require.NoError(t, p.Apply(ctx)) storageSvc, err := core.Inject[contracts.StorageService](ctx) require.NoError(t, err) require.NotNil(t, storageSvc) testCtx := context.Background() // 1. Put content := []byte("Hello, Wavelet Storage Plugin!") putRes, err := storageSvc.Put(testCtx, "uploads/hello.txt", bytes.NewReader(content), int64(len(content)), "text/plain") require.NoError(t, err) assert.Equal(t, "uploads/hello.txt", putRes.Key) assert.Equal(t, "test-bucket", putRes.Bucket) // 2. Get obj, err := storageSvc.Get(testCtx, "uploads/hello.txt") require.NoError(t, err) require.NotNil(t, obj) data, err := io.ReadAll(obj.Body) require.NoError(t, err) assert.Equal(t, content, data) assert.Equal(t, int64(len(content)), obj.ContentLength) // 3. Delete require.NoError(t, storageSvc.Delete(testCtx, "uploads/hello.txt")) _, err = storageSvc.Get(testCtx, "uploads/hello.txt") assert.Error(t, err) } func TestAllInfraPluginsCombined(t *testing.T) { ctx := core.NewContext(context.Background()) testDB := setupTestDB(t) memBackend := newMemoryBackend() require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) require.NoError(t, cache.New().Apply(ctx)) require.NoError(t, logger.New().Apply(ctx)) require.NoError(t, storage.New(storage.WithBackend(memBackend)).Apply(ctx)) // Using3 to resolve dependencies concurrently var resolved bool err := core.Using3(ctx, func(db contracts.DBService, c contracts.CacheService, l contracts.LoggerService) { resolved = true assert.NotNil(t, db) assert.NotNil(t, c) assert.NotNil(t, l) }) require.NoError(t, err) assert.True(t, resolved) // Using storage err = core.Using(ctx, func(s contracts.StorageService) { assert.NotNil(t, s) }) require.NoError(t, err) }