From 6f633e0ca20626fc8a24ba76965f4a7d1dbd62a1 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Fri, 17 Apr 2026 03:30:55 -0400 Subject: [PATCH 1/6] feat: Implement Redis config and lifecycle changes --- backend/cmd/server/main.go | 10 +--- backend/config/config.go | 1 + backend/config/redis.go | 11 +++++ backend/config/redis_test.go | 25 ++++++++++ backend/internal/service/server.go | 46 ++++++++++++++----- backend/internal/service/server_test.go | 33 +++++++++++++ backend/internal/storage/redis/client.go | 35 +++++++------- backend/internal/storage/redis/client_test.go | 41 +++++++---------- 8 files changed, 143 insertions(+), 59 deletions(-) create mode 100644 backend/config/redis.go create mode 100644 backend/config/redis_test.go create mode 100644 backend/internal/service/server_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 93663cb63..961cdffe0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -50,14 +50,8 @@ func main() { } defer func() { - if err := app.Repo.Close(); err != nil { - panic(fmt.Sprintf("failed to close repo: %v", err)) - } - if app.TemporalWorker != nil { - app.TemporalWorker.Stop() - } - if app.TemporalClient != nil { - app.TemporalClient.Close() + if err := app.Close(); err != nil { + panic(fmt.Sprintf("failed to close app resources: %v", err)) } }() diff --git a/backend/config/config.go b/backend/config/config.go index 964044922..cd5b8eb23 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -7,5 +7,6 @@ type Config struct { LLM `env:",prefix=LLM_"` Temporal `env:",prefix=TEMPORAL_"` Clerk `env:",prefix=CLERK_"` + Redis `env:",prefix=REDIS_"` OpenSearch `env:",prefix=OPENSEARCH_"` } diff --git a/backend/config/redis.go b/backend/config/redis.go new file mode 100644 index 000000000..68f87b179 --- /dev/null +++ b/backend/config/redis.go @@ -0,0 +1,11 @@ +package config + +import "time" + +type Redis struct { + Enabled bool `env:"ENABLED,default=false"` + Addr string `env:"ADDR,default=localhost:6379"` + Password string `env:"PASSWORD"` + DB int `env:"DB,default=0"` + PingTimeout time.Duration `env:"PING_TIMEOUT,default=1s"` +} diff --git a/backend/config/redis_test.go b/backend/config/redis_test.go new file mode 100644 index 000000000..ef173f734 --- /dev/null +++ b/backend/config/redis_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "context" + "testing" + "time" + + "github.com/sethvargo/go-envconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedisConfigDefaults(t *testing.T) { + t.Parallel() + + var cfg Redis + err := envconfig.Process(context.Background(), &cfg) + + require.NoError(t, err) + assert.False(t, cfg.Enabled) + assert.Equal(t, "localhost:6379", cfg.Addr) + assert.Equal(t, "", cfg.Password) + assert.Equal(t, 0, cfg.DB) + assert.Equal(t, time.Second, cfg.PingTimeout) +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 749715cc0..cdbd04d98 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -37,6 +37,12 @@ import ( "go.temporal.io/sdk/worker" ) +type redisClient = *goredis.Client + +var initRedisClient = func(cfg config.Redis) (redisClient, error) { + return redis.InitRedis(cfg) +} + type App struct { Server *fiber.App Repo *storage.Repository @@ -46,6 +52,26 @@ type App struct { TemporalWorker worker.Worker } +func (a *App) Close() error { + if a == nil { + return nil + } + + if a.TemporalWorker != nil { + a.TemporalWorker.Stop() + } + if a.TemporalClient != nil { + a.TemporalClient.Close() + } + + var err error + if a.Repo != nil { + err = errors.Join(err, a.Repo.Close()) + } + + return errors.Join(err, redis.Close(a.RedisClient)) +} + func InitApp(cfg *config.Config) (*App, error) { validation.Init() @@ -56,14 +82,11 @@ func InitApp(cfg *config.Config) (*App, error) { return nil, err } - redisClient := tryInitRedis() + redisClient := tryInitRedis(cfg.Redis) s3Store, err := s3storage.NewS3Storage(cfg.S3) if err != nil { - if e := repo.Close(); e != nil { - return nil, errors.Join(err, e) - } - return nil, err + return nil, errors.Join(err, repo.Close(), redis.Close(redisClient)) } openSearchRepos := tryInitOpenSearchRepositories(cfg) @@ -78,13 +101,10 @@ func InitApp(cfg *config.Config) (*App, error) { setupClerk(cfg) if err = setupRoutes(app, repo, genkitInstance, workflowClient, cfg, s3Store, openSearchRepos); err != nil { //nolint:wsl - if e := repo.Close(); e != nil { - return nil, errors.Join(err, e) - } if temporalClient != nil { temporalClient.Close() } - return nil, err + return nil, errors.Join(err, repo.Close(), redis.Close(redisClient)) } return &App{ @@ -116,8 +136,12 @@ func tryInitOpenSearchRepositories(cfg *config.Config) openSearchRepositories { } } -func tryInitRedis() *goredis.Client { - redisClient, err := redis.InitRedis() +func tryInitRedis(cfg config.Redis) *goredis.Client { + if !cfg.Enabled { + return nil + } + + redisClient, err := initRedisClient(cfg) if err != nil { log.Printf("Warning: Redis not available: %v", err) return nil diff --git a/backend/internal/service/server_test.go b/backend/internal/service/server_test.go new file mode 100644 index 000000000..8c9ae4d90 --- /dev/null +++ b/backend/internal/service/server_test.go @@ -0,0 +1,33 @@ +package service + +import ( + "errors" + "testing" + + "github.com/generate/selfserve/config" + "github.com/stretchr/testify/assert" +) + +func TestTryInitRedisReturnsNilWhenDisabled(t *testing.T) { + t.Parallel() + + client := tryInitRedis(config.Redis{}) + + assert.Nil(t, client) +} + +func TestTryInitRedisReturnsNilOnInitError(t *testing.T) { + t.Parallel() + + original := initRedisClient + initRedisClient = func(config.Redis) (redisClient, error) { + return nil, errors.New("boom") + } + t.Cleanup(func() { + initRedisClient = original + }) + + client := tryInitRedis(config.Redis{Enabled: true, Addr: "cache.internal:6379"}) + + assert.Nil(t, client) +} diff --git a/backend/internal/storage/redis/client.go b/backend/internal/storage/redis/client.go index bb749b039..8aaa873ab 100644 --- a/backend/internal/storage/redis/client.go +++ b/backend/internal/storage/redis/client.go @@ -3,21 +3,21 @@ package redis import ( "context" "fmt" - "os" + "time" + "github.com/generate/selfserve/config" "github.com/redis/go-redis/v9" ) // InitRedis initializes and returns a Redis client -func InitRedis() (*redis.Client, error) { - client := redis.NewClient(&redis.Options{ - Addr: getRedisAddr(), - Password: getRedisPassword(), - DB: 0, - }) - - ctx := context.Background() +func InitRedis(cfg config.Redis) (*redis.Client, error) { + client := redis.NewClient(newOptions(cfg)) + + ctx, cancel := context.WithTimeout(context.Background(), pingTimeout(cfg)) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() return nil, fmt.Errorf("failed to connect to Redis: %w", err) } @@ -32,14 +32,17 @@ func Close(client *redis.Client) error { return nil } -func getRedisAddr() string { - addr := os.Getenv("REDIS_ADDR") - if addr == "" { - return "localhost:6379" +func newOptions(cfg config.Redis) *redis.Options { + return &redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, } - return addr } -func getRedisPassword() string { - return os.Getenv("REDIS_PASSWORD") +func pingTimeout(cfg config.Redis) time.Duration { + if cfg.PingTimeout <= 0 { + return time.Second + } + return cfg.PingTimeout } diff --git a/backend/internal/storage/redis/client_test.go b/backend/internal/storage/redis/client_test.go index a171e6ea6..e77ef8f4a 100644 --- a/backend/internal/storage/redis/client_test.go +++ b/backend/internal/storage/redis/client_test.go @@ -1,34 +1,27 @@ package redis import ( - "context" "testing" -) + "time" -func TestRedisConnection(t *testing.T) { - client, err := InitRedis() - if err != nil { - t.Skipf("Skipping test: Redis not available: %v", err) - } - defer func() { - _ = Close(client) - }() + "github.com/generate/selfserve/config" + "github.com/stretchr/testify/assert" +) - ctx := context.Background() +func TestNewOptionsUsesConfigValues(t *testing.T) { + t.Parallel() - // Test Set - err = client.Set(ctx, "test_key", "test_value", 0).Err() - if err != nil { - t.Fatalf("Failed to set value: %v", err) + cfg := config.Redis{ + Enabled: true, + Addr: "cache.internal:6379", + Password: "secret", + DB: 7, + PingTimeout: 3 * time.Second, } - defer client.Del(ctx, "test_key") - // Test Get - val, err := client.Get(ctx, "test_key").Result() - if err != nil { - t.Fatalf("Failed to get value: %v", err) - } - if val != "test_value" { - t.Errorf("Expected 'test_value', got '%s'", val) - } + options := newOptions(cfg) + + assert.Equal(t, "cache.internal:6379", options.Addr) + assert.Equal(t, "secret", options.Password) + assert.Equal(t, 7, options.DB) } From 464136be44705469315579c8b86c5048744bfc90 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Fri, 17 Apr 2026 03:35:13 -0400 Subject: [PATCH 2/6] feat: Implement KV store and object cache layers --- backend/internal/cache/object/cache.go | 74 ++++++++++++ backend/internal/cache/object/cache_test.go | 114 ++++++++++++++++++ backend/internal/cache/store/redis_store.go | 52 ++++++++ .../internal/cache/store/redis_store_test.go | 41 +++++++ backend/internal/cache/store/store.go | 15 +++ 5 files changed, 296 insertions(+) create mode 100644 backend/internal/cache/object/cache.go create mode 100644 backend/internal/cache/object/cache_test.go create mode 100644 backend/internal/cache/store/redis_store.go create mode 100644 backend/internal/cache/store/redis_store_test.go create mode 100644 backend/internal/cache/store/store.go diff --git a/backend/internal/cache/object/cache.go b/backend/internal/cache/object/cache.go new file mode 100644 index 000000000..ce3a4708f --- /dev/null +++ b/backend/internal/cache/object/cache.go @@ -0,0 +1,74 @@ +package object + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "time" + + "github.com/generate/selfserve/internal/cache/store" +) + +type Cache struct { + store store.KVStore + logger *slog.Logger +} + +func New(cacheStore store.KVStore, logger ...*slog.Logger) *Cache { + if cacheStore == nil { + return nil + } + + resolvedLogger := slog.Default() + if len(logger) > 0 && logger[0] != nil { + resolvedLogger = logger[0] + } + + return &Cache{store: cacheStore, logger: resolvedLogger} +} + +func (c *Cache) Get(ctx context.Context, key string, dest any) (bool, error) { + value, err := c.store.Get(ctx, key) + if errors.Is(err, store.ErrCacheMiss) { + return false, nil + } + if err != nil { + return false, err + } + if err := json.Unmarshal([]byte(value), dest); err != nil { + return false, err + } + return true, nil +} + +func (c *Cache) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + encoded, err := json.Marshal(value) + if err != nil { + return err + } + return c.store.Set(ctx, key, string(encoded), ttl) +} + +func (c *Cache) Delete(ctx context.Context, key string) error { + return c.store.Delete(ctx, key) +} + +func (c *Cache) WarnReadError(key string, err error) { + c.warn("redis cache read failed", key, err) +} + +func (c *Cache) WarnWriteError(key string, err error) { + c.warn("redis cache write failed", key, err) +} + +func (c *Cache) WarnDeleteError(key string, err error) { + c.warn("redis cache delete failed", key, err) +} + +func (c *Cache) warn(message, key string, err error) { + if c == nil || err == nil { + return + } + c.logger.Warn(message, "key", key, "err", err) +} diff --git a/backend/internal/cache/object/cache_test.go b/backend/internal/cache/object/cache_test.go new file mode 100644 index 000000000..80391f702 --- /dev/null +++ b/backend/internal/cache/object/cache_test.go @@ -0,0 +1,114 @@ +package object + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/generate/selfserve/internal/cache/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeKVStore struct { + values map[string]string + getErr error + setErr error + deleteErr error + lastSetKey string + lastSetValue string + lastSetTTL time.Duration + lastDelete string +} + +func (s *fakeKVStore) Get(_ context.Context, key string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + value, ok := s.values[key] + if !ok { + return "", store.ErrCacheMiss + } + return value, nil +} + +func (s *fakeKVStore) Set(_ context.Context, key string, value string, ttl time.Duration) error { + if s.setErr != nil { + return s.setErr + } + if s.values == nil { + s.values = map[string]string{} + } + s.values[key] = value + s.lastSetKey = key + s.lastSetValue = value + s.lastSetTTL = ttl + return nil +} + +func (s *fakeKVStore) Delete(_ context.Context, key string) error { + if s.deleteErr != nil { + return s.deleteErr + } + s.lastDelete = key + delete(s.values, key) + return nil +} + +func TestCacheGetReturnsMissWithoutError(t *testing.T) { + t.Parallel() + + cache := New(&fakeKVStore{}) + + var user struct { + Name string `json:"name"` + } + hit, err := cache.Get(context.Background(), "missing", &user) + + require.NoError(t, err) + assert.False(t, hit) +} + +func TestCacheSetStoresJSONValue(t *testing.T) { + t.Parallel() + + store := &fakeKVStore{} + cache := New(store) + + err := cache.Set(context.Background(), "users:1", struct { + Name string `json:"name"` + }{ + Name: "Ada", + }, 2*time.Minute) + + require.NoError(t, err) + assert.Equal(t, "users:1", store.lastSetKey) + assert.JSONEq(t, `{"name":"Ada"}`, store.lastSetValue) + assert.Equal(t, 2*time.Minute, store.lastSetTTL) +} + +func TestCacheDeleteForwardsToStore(t *testing.T) { + t.Parallel() + + store := &fakeKVStore{values: map[string]string{"users:1": `{"name":"Ada"}`}} + cache := New(store) + + err := cache.Delete(context.Background(), "users:1") + + require.NoError(t, err) + assert.Equal(t, "users:1", store.lastDelete) +} + +func TestCacheGetReturnsStoreError(t *testing.T) { + t.Parallel() + + expected := errors.New("boom") + cache := New(&fakeKVStore{getErr: expected}) + + var user struct{} + hit, err := cache.Get(context.Background(), "users:1", &user) + + assert.False(t, hit) + require.ErrorIs(t, err, expected) +} diff --git a/backend/internal/cache/store/redis_store.go b/backend/internal/cache/store/redis_store.go new file mode 100644 index 000000000..3627a6baf --- /dev/null +++ b/backend/internal/cache/store/redis_store.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + "errors" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +type RedisStore struct { + getValue func(ctx context.Context, key string) (string, error) + setValue func(ctx context.Context, key string, value string, ttl time.Duration) error + deleteValue func(ctx context.Context, key string) error +} + +func NewRedisStore(client *goredis.Client) *RedisStore { + if client == nil { + return nil + } + + return &RedisStore{ + getValue: func(ctx context.Context, key string) (string, error) { + return client.Get(ctx, key).Result() + }, + setValue: func(ctx context.Context, key string, value string, ttl time.Duration) error { + return client.Set(ctx, key, value, ttl).Err() + }, + deleteValue: func(ctx context.Context, key string) error { + return client.Del(ctx, key).Err() + }, + } +} + +func (s *RedisStore) Get(ctx context.Context, key string) (string, error) { + value, err := s.getValue(ctx, key) + if errors.Is(err, goredis.Nil) { + return "", ErrCacheMiss + } + if err != nil { + return "", err + } + return value, nil +} + +func (s *RedisStore) Set(ctx context.Context, key string, value string, ttl time.Duration) error { + return s.setValue(ctx, key, value, ttl) +} + +func (s *RedisStore) Delete(ctx context.Context, key string) error { + return s.deleteValue(ctx, key) +} diff --git a/backend/internal/cache/store/redis_store_test.go b/backend/internal/cache/store/redis_store_test.go new file mode 100644 index 000000000..94b8f3958 --- /dev/null +++ b/backend/internal/cache/store/redis_store_test.go @@ -0,0 +1,41 @@ +package store + +import ( + "context" + "errors" + "testing" + + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedisStoreGetTranslatesRedisNil(t *testing.T) { + t.Parallel() + + cache := &RedisStore{ + getValue: func(context.Context, string) (string, error) { + return "", goredis.Nil + }, + } + + value, err := cache.Get(context.Background(), "missing") + + assert.Empty(t, value) + require.ErrorIs(t, err, ErrCacheMiss) +} + +func TestRedisStoreDeleteForwardsErrors(t *testing.T) { + t.Parallel() + + expected := errors.New("boom") + cache := &RedisStore{ + deleteValue: func(context.Context, string) error { + return expected + }, + } + + err := cache.Delete(context.Background(), "users:1") + + require.ErrorIs(t, err, expected) +} diff --git a/backend/internal/cache/store/store.go b/backend/internal/cache/store/store.go new file mode 100644 index 000000000..134300ee9 --- /dev/null +++ b/backend/internal/cache/store/store.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + "errors" + "time" +) + +var ErrCacheMiss = errors.New("cache miss") + +type KVStore interface { + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key string, value string, ttl time.Duration) error + Delete(ctx context.Context, key string) error +} From f1c47543da2e43f509e517dcf07c58408de72bde Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Fri, 17 Apr 2026 03:49:47 -0400 Subject: [PATCH 3/6] feat: Add user-domain cache helpers and user cache decorator --- backend/internal/cache/domain/keys.go | 7 + backend/internal/cache/domain/policy.go | 7 + backend/internal/cache/domain/users.go | 128 ++++++++++++ backend/internal/cache/domain/users_test.go | 208 ++++++++++++++++++++ backend/internal/service/cached_repos.go | 15 ++ backend/internal/service/server.go | 10 +- 6 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 backend/internal/cache/domain/keys.go create mode 100644 backend/internal/cache/domain/policy.go create mode 100644 backend/internal/cache/domain/users.go create mode 100644 backend/internal/cache/domain/users_test.go create mode 100644 backend/internal/service/cached_repos.go diff --git a/backend/internal/cache/domain/keys.go b/backend/internal/cache/domain/keys.go new file mode 100644 index 000000000..66a2be98e --- /dev/null +++ b/backend/internal/cache/domain/keys.go @@ -0,0 +1,7 @@ +package domain + +const keyPrefix = "selfserve:v1" + +func userKey(id string) string { + return keyPrefix + ":users:" + id +} diff --git a/backend/internal/cache/domain/policy.go b/backend/internal/cache/domain/policy.go new file mode 100644 index 000000000..a14f71fc3 --- /dev/null +++ b/backend/internal/cache/domain/policy.go @@ -0,0 +1,7 @@ +package domain + +import "time" + +const ( + userTTL = 5 * time.Minute +) diff --git a/backend/internal/cache/domain/users.go b/backend/internal/cache/domain/users.go new file mode 100644 index 000000000..16ffe6c22 --- /dev/null +++ b/backend/internal/cache/domain/users.go @@ -0,0 +1,128 @@ +package domain + +import ( + "context" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedUsersRepository struct { + cache *object.Cache + next storage.UsersRepository +} + +func NewCachedUsersRepository(cache *object.Cache, next storage.UsersRepository) *CachedUsersRepository { + return &CachedUsersRepository{cache: cache, next: next} +} + +func (r *CachedUsersRepository) FindUser(ctx context.Context, id string) (*models.User, error) { + if r.cache == nil { + return r.next.FindUser(ctx, id) + } + + key := userKey(id) + var cached models.User + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + user, err := r.next.FindUser(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, user, userTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return user, nil +} + +func (r *CachedUsersRepository) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { + created, err := r.next.InsertUser(ctx, user) + if err != nil { + return nil, err + } + r.invalidateUser(ctx, created.ID) + return created, nil +} + +func (r *CachedUsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { + updated, err := r.next.UpdateUser(ctx, id, update) + if err != nil { + return nil, err + } + r.invalidateUser(ctx, id) + return updated, nil +} + +func (r *CachedUsersRepository) UpdateProfilePicture(ctx context.Context, userID string, key string) error { + if err := r.next.UpdateProfilePicture(ctx, userID, key); err != nil { + return err + } + r.invalidateUser(ctx, userID) + return nil +} + +func (r *CachedUsersRepository) DeleteProfilePicture(ctx context.Context, userID string) error { + if err := r.next.DeleteProfilePicture(ctx, userID); err != nil { + return err + } + r.invalidateUser(ctx, userID) + return nil +} + +func (r *CachedUsersRepository) GetKey(ctx context.Context, userID string) (string, error) { + return r.next.GetKey(ctx, userID) +} + +func (r *CachedUsersRepository) BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error { + return r.next.BulkInsertUsers(ctx, users) +} + +func (r *CachedUsersRepository) GetUsersByHotel(ctx context.Context, hotelID, cursor string, limit int) ([]*models.User, string, error) { + return r.next.GetUsersByHotel(ctx, hotelID, cursor, limit) +} + +func (r *CachedUsersRepository) SearchUsersByHotel(ctx context.Context, hotelID, cursor, query string, limit int) ([]*models.User, string, error) { + return r.next.SearchUsersByHotel(ctx, hotelID, cursor, query, limit) +} + +func (r *CachedUsersRepository) AddEmployeeDepartment(ctx context.Context, employeeID, departmentID string) error { + if err := r.next.AddEmployeeDepartment(ctx, employeeID, departmentID); err != nil { + return err + } + r.invalidateUser(ctx, employeeID) + return nil +} + +func (r *CachedUsersRepository) RemoveEmployeeDepartment(ctx context.Context, employeeID, departmentID string) error { + if err := r.next.RemoveEmployeeDepartment(ctx, employeeID, departmentID); err != nil { + return err + } + r.invalidateUser(ctx, employeeID) + return nil +} + +func (r *CachedUsersRepository) CompleteOnboarding(ctx context.Context, id string, data *models.OnboardUser) (*models.User, error) { + user, err := r.next.CompleteOnboarding(ctx, id, data) + if err != nil { + return nil, err + } + r.invalidateUser(ctx, id) + return user, nil +} + +func (r *CachedUsersRepository) invalidateUser(ctx context.Context, id string) { + if r.cache == nil || id == "" { + return + } + + key := userKey(id) + if err := r.cache.Delete(ctx, key); err != nil { + r.cache.WarnDeleteError(key, err) + } +} diff --git a/backend/internal/cache/domain/users_test.go b/backend/internal/cache/domain/users_test.go new file mode 100644 index 000000000..48182aae1 --- /dev/null +++ b/backend/internal/cache/domain/users_test.go @@ -0,0 +1,208 @@ +package domain + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/cache/store" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeKVStore struct { + values map[string]string + getErr error + setErr error + deleteErr error + lastSetKey string + lastDeleted string +} + +func (s *fakeKVStore) Get(_ context.Context, key string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + value, ok := s.values[key] + if !ok { + return "", store.ErrCacheMiss + } + return value, nil +} + +func (s *fakeKVStore) Set(_ context.Context, key string, value string, _ time.Duration) error { + if s.setErr != nil { + return s.setErr + } + if s.values == nil { + s.values = map[string]string{} + } + s.values[key] = value + s.lastSetKey = key + return nil +} + +func (s *fakeKVStore) Delete(_ context.Context, key string) error { + if s.deleteErr != nil { + return s.deleteErr + } + s.lastDeleted = key + delete(s.values, key) + return nil +} + +type stubUsersRepo struct { + findUserCalls int + findUserFn func(ctx context.Context, id string) (*models.User, error) + updateUserFn func(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) + updatePicFn func(ctx context.Context, userID, key string) error +} + +func (s *stubUsersRepo) FindUser(ctx context.Context, id string) (*models.User, error) { + s.findUserCalls++ + return s.findUserFn(ctx, id) +} + +func (s *stubUsersRepo) InsertUser(_ context.Context, user *models.CreateUser) (*models.User, error) { + return &models.User{CreateUser: *user}, nil +} + +func (s *stubUsersRepo) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { + return s.updateUserFn(ctx, id, update) +} + +func (s *stubUsersRepo) UpdateProfilePicture(ctx context.Context, userID string, key string) error { + return s.updatePicFn(ctx, userID, key) +} + +func (s *stubUsersRepo) DeleteProfilePicture(_ context.Context, _ string) error { return nil } +func (s *stubUsersRepo) GetKey(_ context.Context, _ string) (string, error) { return "", nil } +func (s *stubUsersRepo) BulkInsertUsers(_ context.Context, _ []*models.CreateUser) error { + return nil +} +func (s *stubUsersRepo) GetUsersByHotel(_ context.Context, _, _ string, _ int) ([]*models.User, string, error) { + return nil, "", nil +} +func (s *stubUsersRepo) SearchUsersByHotel(_ context.Context, _, _, _ string, _ int) ([]*models.User, string, error) { + return nil, "", nil +} +func (s *stubUsersRepo) AddEmployeeDepartment(_ context.Context, _, _ string) error { return nil } +func (s *stubUsersRepo) RemoveEmployeeDepartment(_ context.Context, _, _ string) error { + return nil +} +func (s *stubUsersRepo) CompleteOnboarding(_ context.Context, _ string, _ *models.OnboardUser) (*models.User, error) { + return nil, nil +} + +var _ storage.UsersRepository = (*stubUsersRepo)(nil) + +func TestUserKeyIsDeterministic(t *testing.T) { + t.Parallel() + + assert.Equal(t, "selfserve:v1:users:user-1", userKey("user-1")) +} + +func TestCachedUsersRepositoryFindUserUsesCacheHit(t *testing.T) { + t.Parallel() + + cacheStore := &fakeKVStore{ + values: map[string]string{ + userKey("user-1"): `{"id":"user-1","first_name":"Ada"}`, + }, + } + repo := &stubUsersRepo{ + findUserFn: func(context.Context, string) (*models.User, error) { + t.Fatal("repo should not be called on cache hit") + return nil, nil + }, + } + + cached := NewCachedUsersRepository(object.New(cacheStore), repo) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", user.FirstName) + assert.Equal(t, 0, repo.findUserCalls) +} + +func TestCachedUsersRepositoryFindUserCachesRepositoryResult(t *testing.T) { + t.Parallel() + + cacheStore := &fakeKVStore{} + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil + }, + } + + cached := NewCachedUsersRepository(object.New(cacheStore), repo) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", user.FirstName) + assert.Equal(t, userKey("user-1"), cacheStore.lastSetKey) +} + +func TestCachedUsersRepositoryFindUserFailsOpenOnCacheReadError(t *testing.T) { + t.Parallel() + + cacheStore := &fakeKVStore{getErr: errors.New("redis unavailable")} + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil + }, + } + + cached := NewCachedUsersRepository(object.New(cacheStore), repo) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", user.FirstName) + assert.Equal(t, 1, repo.findUserCalls) +} + +func TestCachedUsersRepositoryUpdateUserInvalidatesOnSuccess(t *testing.T) { + t.Parallel() + + cacheStore := &fakeKVStore{values: map[string]string{userKey("user-1"): `{"id":"user-1"}`}} + repo := &stubUsersRepo{ + findUserFn: func(context.Context, string) (*models.User, error) { return nil, nil }, + updateUserFn: func(_ context.Context, id string, update *models.UpdateUser) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, PhoneNumber: update.PhoneNumber}}, nil + }, + updatePicFn: func(context.Context, string, string) error { return nil }, + } + + cached := NewCachedUsersRepository(object.New(cacheStore), repo) + _, err := cached.UpdateUser(context.Background(), "user-1", &models.UpdateUser{PhoneNumber: stringPtr("123")}) + + require.NoError(t, err) + assert.Equal(t, userKey("user-1"), cacheStore.lastDeleted) +} + +func TestCachedUsersRepositoryUpdateProfilePictureInvalidatesOnSuccess(t *testing.T) { + t.Parallel() + + cacheStore := &fakeKVStore{values: map[string]string{userKey("user-1"): `{"id":"user-1"}`}} + repo := &stubUsersRepo{ + findUserFn: func(context.Context, string) (*models.User, error) { return nil, nil }, + updateUserFn: func(context.Context, string, *models.UpdateUser) (*models.User, error) { + return nil, nil + }, + updatePicFn: func(context.Context, string, string) error { return nil }, + } + + cached := NewCachedUsersRepository(object.New(cacheStore), repo) + err := cached.UpdateProfilePicture(context.Background(), "user-1", "profile-key") + + require.NoError(t, err) + assert.Equal(t, userKey("user-1"), cacheStore.lastDeleted) +} + +func stringPtr(value string) *string { + return &value +} diff --git a/backend/internal/service/cached_repos.go b/backend/internal/service/cached_repos.go new file mode 100644 index 000000000..e43a3645b --- /dev/null +++ b/backend/internal/service/cached_repos.go @@ -0,0 +1,15 @@ +package service + +import ( + "github.com/generate/selfserve/internal/cache/domain" + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/repository" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +func buildUsersRepository(cache *object.Cache, repo *repository.UsersRepository) storage.UsersRepository { + if cache == nil { + return repo + } + return domain.NewCachedUsersRepository(cache, repo) +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index cdbd04d98..df34621dc 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -11,6 +11,8 @@ import ( clerksdk "github.com/clerk/clerk-sdk-go/v2" "github.com/generate/selfserve/config" "github.com/generate/selfserve/internal/aiflows" + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/cache/store" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/repository" @@ -83,6 +85,7 @@ func InitApp(cfg *config.Config) (*App, error) { } redisClient := tryInitRedis(cfg.Redis) + objectCache := object.New(store.NewRedisStore(redisClient)) s3Store, err := s3storage.NewS3Storage(cfg.S3) if err != nil { @@ -100,7 +103,7 @@ func InitApp(cfg *config.Config) (*App, error) { app := setupApp() setupClerk(cfg) - if err = setupRoutes(app, repo, genkitInstance, workflowClient, cfg, s3Store, openSearchRepos); err != nil { //nolint:wsl + if err = setupRoutes(app, repo, genkitInstance, workflowClient, cfg, s3Store, openSearchRepos, objectCache); err != nil { //nolint:wsl if temporalClient != nil { temporalClient.Close() } @@ -168,7 +171,7 @@ func tryInitTemporal(cfg *config.Config, genkitService aiflows.GenerateRequestSe } func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflows.GenkitService, - workflowClient temporalservice.GenerateRequestWorkflowClient, cfg *config.Config, s3Store *s3storage.Storage, openSearchRepos openSearchRepositories) error { + workflowClient temporalservice.GenerateRequestWorkflowClient, cfg *config.Config, s3Store *s3storage.Storage, openSearchRepos openSearchRepositories, objectCache *object.Cache) error { // Swagger documentation app.Get("/swagger/*", handler.ServeSwagger) @@ -185,6 +188,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // initialize users and hotels repos for clerk webhook handler usersRepo := repository.NewUsersRepository(repo.DB) hotelsRepo := repository.NewHotelsRepository(repo.DB) + usersReadRepo := buildUsersRepository(objectCache, usersRepo) // initialize notifications notifRepo := repository.NewNotificationsRepository(repo.DB) @@ -194,7 +198,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // initialize handler(s) helloHandler := handler.NewHelloHandler() devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) - usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB), s3Store) + usersHandler := handler.NewUsersHandler(usersReadRepo, s3Store) guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), repository.NewUsersRepository(repo.DB), openSearchRepos.Guests) reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) reqsHandler.WorkflowClient = workflowClient From 3f5b6c8d1a758b18e769e0cb6df3b8eeabaf08f5 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Fri, 17 Apr 2026 03:56:06 -0400 Subject: [PATCH 4/6] feat: Add the remaining cache keys and service wiring --- .../internal/cache/domain/guest_bookings.go | 47 ++++++++ .../cache/domain/guest_bookings_test.go | 43 ++++++++ backend/internal/cache/domain/guests.go | 100 +++++++++++++++++ backend/internal/cache/domain/guests_test.go | 103 ++++++++++++++++++ backend/internal/cache/domain/hotels.go | 62 +++++++++++ backend/internal/cache/domain/hotels_test.go | 96 ++++++++++++++++ backend/internal/cache/domain/keys.go | 16 +++ backend/internal/cache/domain/policy.go | 6 +- backend/internal/service/cached_repos.go | 22 ++++ backend/internal/service/server.go | 11 +- 10 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 backend/internal/cache/domain/guest_bookings.go create mode 100644 backend/internal/cache/domain/guest_bookings_test.go create mode 100644 backend/internal/cache/domain/guests.go create mode 100644 backend/internal/cache/domain/guests_test.go create mode 100644 backend/internal/cache/domain/hotels.go create mode 100644 backend/internal/cache/domain/hotels_test.go diff --git a/backend/internal/cache/domain/guest_bookings.go b/backend/internal/cache/domain/guest_bookings.go new file mode 100644 index 000000000..9f7f5a918 --- /dev/null +++ b/backend/internal/cache/domain/guest_bookings.go @@ -0,0 +1,47 @@ +package domain + +import ( + "context" + "time" + + "github.com/generate/selfserve/internal/cache/object" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedGuestBookingsRepository struct { + cache *object.Cache + next storage.GuestBookingsRepository +} + +func NewCachedGuestBookingsRepository(cache *object.Cache, next storage.GuestBookingsRepository) *CachedGuestBookingsRepository { + return &CachedGuestBookingsRepository{cache: cache, next: next} +} + +func (r *CachedGuestBookingsRepository) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) { + if r.cache == nil { + return r.next.FindGroupSizeOptions(ctx, hotelID) + } + + key := guestBookingGroupSizesKey(hotelID) + var cached []int + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return cached, nil + } + + sizes, err := r.next.FindGroupSizeOptions(ctx, hotelID) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, sizes, guestBookingGroupSizesTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return sizes, nil +} + +func (r *CachedGuestBookingsRepository) InsertGuestBooking(ctx context.Context, guestID, roomID, hotelID string, arrivalDate, departureDate time.Time) error { + // Future guest booking writes should invalidate hotel group sizes and affected guest stay history. + return r.next.InsertGuestBooking(ctx, guestID, roomID, hotelID, arrivalDate, departureDate) +} diff --git a/backend/internal/cache/domain/guest_bookings_test.go b/backend/internal/cache/domain/guest_bookings_test.go new file mode 100644 index 000000000..10882e882 --- /dev/null +++ b/backend/internal/cache/domain/guest_bookings_test.go @@ -0,0 +1,43 @@ +package domain + +import ( + "context" + "testing" + "time" + + "github.com/generate/selfserve/internal/cache/object" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubGuestBookingsRepo struct { + findGroupSizeOptionsFn func(ctx context.Context, hotelID string) ([]int, error) +} + +func (s *stubGuestBookingsRepo) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) { + return s.findGroupSizeOptionsFn(ctx, hotelID) +} +func (s *stubGuestBookingsRepo) InsertGuestBooking(_ context.Context, _, _, _ string, _, _ time.Time) error { + return nil +} + +var _ storage.GuestBookingsRepository = (*stubGuestBookingsRepo)(nil) + +func TestCachedGuestBookingsRepositoryUsesHotelScopedKey(t *testing.T) { + t.Parallel() + + cacheStore := &fakeDomainStore{} + repo := &stubGuestBookingsRepo{ + findGroupSizeOptionsFn: func(context.Context, string) ([]int, error) { + return []int{1, 2, 4}, nil + }, + } + + cached := NewCachedGuestBookingsRepository(object.New(cacheStore), repo) + sizes, err := cached.FindGroupSizeOptions(context.Background(), "hotel-1") + + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 4}, sizes) + assert.Equal(t, guestBookingGroupSizesKey("hotel-1"), cacheStore.lastSetKey) +} diff --git a/backend/internal/cache/domain/guests.go b/backend/internal/cache/domain/guests.go new file mode 100644 index 000000000..66b271304 --- /dev/null +++ b/backend/internal/cache/domain/guests.go @@ -0,0 +1,100 @@ +package domain + +import ( + "context" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedGuestsRepository struct { + cache *object.Cache + next storage.GuestsRepository +} + +func NewCachedGuestsRepository(cache *object.Cache, next storage.GuestsRepository) *CachedGuestsRepository { + return &CachedGuestsRepository{cache: cache, next: next} +} + +func (r *CachedGuestsRepository) InsertGuest(ctx context.Context, guest *models.CreateGuest) (*models.Guest, error) { + created, err := r.next.InsertGuest(ctx, guest) + if err != nil { + return nil, err + } + r.invalidateGuest(ctx, created.ID) + return created, nil +} + +func (r *CachedGuestsRepository) FindGuest(ctx context.Context, id string) (*models.Guest, error) { + if r.cache == nil { + return r.next.FindGuest(ctx, id) + } + + key := guestKey(id) + var cached models.Guest + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + guest, err := r.next.FindGuest(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, guest, guestTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return guest, nil +} + +func (r *CachedGuestsRepository) UpdateGuest(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + updated, err := r.next.UpdateGuest(ctx, id, update) + if err != nil { + return nil, err + } + r.invalidateGuest(ctx, id) + return updated, nil +} + +func (r *CachedGuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filters *models.GuestFilters) (*models.GuestPage, error) { + return r.next.FindGuestsWithActiveBooking(ctx, filters) +} + +func (r *CachedGuestsRepository) FindGuestWithStayHistory(ctx context.Context, id string) (*models.GuestWithStays, error) { + if r.cache == nil { + return r.next.FindGuestWithStayHistory(ctx, id) + } + + key := guestStayHistoryKey(id) + var cached models.GuestWithStays + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + guest, err := r.next.FindGuestWithStayHistory(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, guest, guestStayHistoryTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return guest, nil +} + +func (r *CachedGuestsRepository) invalidateGuest(ctx context.Context, id string) { + if r.cache == nil || id == "" { + return + } + + for _, key := range []string{guestKey(id), guestStayHistoryKey(id)} { + if err := r.cache.Delete(ctx, key); err != nil { + r.cache.WarnDeleteError(key, err) + } + } +} diff --git a/backend/internal/cache/domain/guests_test.go b/backend/internal/cache/domain/guests_test.go new file mode 100644 index 000000000..9b0f73f3d --- /dev/null +++ b/backend/internal/cache/domain/guests_test.go @@ -0,0 +1,103 @@ +package domain + +import ( + "context" + "testing" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubGuestsRepo struct { + findGuestFn func(ctx context.Context, id string) (*models.Guest, error) + findGuestStaysFn func(ctx context.Context, id string) (*models.GuestWithStays, error) + updateGuestFn func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) +} + +func (s *stubGuestsRepo) InsertGuest(_ context.Context, guest *models.CreateGuest) (*models.Guest, error) { + return &models.Guest{CreateGuest: *guest}, nil +} +func (s *stubGuestsRepo) FindGuest(ctx context.Context, id string) (*models.Guest, error) { + return s.findGuestFn(ctx, id) +} +func (s *stubGuestsRepo) UpdateGuest(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + return s.updateGuestFn(ctx, id, update) +} +func (s *stubGuestsRepo) FindGuestsWithActiveBooking(_ context.Context, _ *models.GuestFilters) (*models.GuestPage, error) { + return nil, nil +} +func (s *stubGuestsRepo) FindGuestWithStayHistory(ctx context.Context, id string) (*models.GuestWithStays, error) { + return s.findGuestStaysFn(ctx, id) +} + +var _ storage.GuestsRepository = (*stubGuestsRepo)(nil) + +func TestCachedGuestsRepositoryCachesStayHistory(t *testing.T) { + t.Parallel() + + cacheStore := &fakeDomainStore{} + repo := &stubGuestsRepo{ + findGuestFn: func(context.Context, string) (*models.Guest, error) { return nil, nil }, + findGuestStaysFn: func(_ context.Context, id string) (*models.GuestWithStays, error) { + return &models.GuestWithStays{ID: id, FirstName: "Ada"}, nil + }, + updateGuestFn: func(context.Context, string, *models.UpdateGuest) (*models.Guest, error) { return nil, nil }, + } + + cached := NewCachedGuestsRepository(object.New(cacheStore), repo) + guest, err := cached.FindGuestWithStayHistory(context.Background(), "guest-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", guest.FirstName) + assert.Equal(t, guestStayHistoryKey("guest-1"), cacheStore.lastSetKey) +} + +func TestCachedGuestsRepositoryCachesFindGuest(t *testing.T) { + t.Parallel() + + cacheStore := &fakeDomainStore{} + repo := &stubGuestsRepo{ + findGuestFn: func(_ context.Context, id string) (*models.Guest, error) { + return &models.Guest{ID: id, CreateGuest: models.CreateGuest{FirstName: "Ada"}}, nil + }, + findGuestStaysFn: func(context.Context, string) (*models.GuestWithStays, error) { return nil, nil }, + updateGuestFn: func(context.Context, string, *models.UpdateGuest) (*models.Guest, error) { return nil, nil }, + } + + cached := NewCachedGuestsRepository(object.New(cacheStore), repo) + guest, err := cached.FindGuest(context.Background(), "guest-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", guest.FirstName) + assert.Equal(t, guestKey("guest-1"), cacheStore.lastSetKey) +} + +func TestCachedGuestsRepositoryUpdateGuestInvalidatesBothKeys(t *testing.T) { + t.Parallel() + + cacheStore := &fakeDomainStore{ + values: map[string]string{ + guestKey("guest-1"): `{"id":"guest-1"}`, + guestStayHistoryKey("guest-1"): `{"id":"guest-1"}`, + }, + } + repo := &stubGuestsRepo{ + findGuestFn: func(context.Context, string) (*models.Guest, error) { return nil, nil }, + findGuestStaysFn: func(context.Context, string) (*models.GuestWithStays, error) { return nil, nil }, + updateGuestFn: func(_ context.Context, id string, _ *models.UpdateGuest) (*models.Guest, error) { + return &models.Guest{ID: id}, nil + }, + } + + cached := NewCachedGuestsRepository(object.New(cacheStore), repo) + _, err := cached.UpdateGuest(context.Background(), "guest-1", &models.UpdateGuest{}) + + require.NoError(t, err) + _, guestCached := cacheStore.values[guestKey("guest-1")] + _, stayCached := cacheStore.values[guestStayHistoryKey("guest-1")] + assert.False(t, guestCached) + assert.False(t, stayCached) +} diff --git a/backend/internal/cache/domain/hotels.go b/backend/internal/cache/domain/hotels.go new file mode 100644 index 000000000..0e50b4d8c --- /dev/null +++ b/backend/internal/cache/domain/hotels.go @@ -0,0 +1,62 @@ +package domain + +import ( + "context" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedHotelsRepository struct { + cache *object.Cache + next storage.HotelsRepository +} + +func NewCachedHotelsRepository(cache *object.Cache, next storage.HotelsRepository) *CachedHotelsRepository { + return &CachedHotelsRepository{cache: cache, next: next} +} + +func (r *CachedHotelsRepository) FindByID(ctx context.Context, id string) (*models.Hotel, error) { + if r.cache == nil { + return r.next.FindByID(ctx, id) + } + + key := hotelKey(id) + var cached models.Hotel + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + hotel, err := r.next.FindByID(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, hotel, hotelTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return hotel, nil +} + +func (r *CachedHotelsRepository) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return r.next.InsertHotel(ctx, hotel) +} + +func (r *CachedHotelsRepository) GetDepartmentsByHotelID(ctx context.Context, hotelID string) ([]*models.Department, error) { + return r.next.GetDepartmentsByHotelID(ctx, hotelID) +} + +func (r *CachedHotelsRepository) InsertDepartment(ctx context.Context, hotelID, name string) (*models.Department, error) { + return r.next.InsertDepartment(ctx, hotelID, name) +} + +func (r *CachedHotelsRepository) UpdateDepartment(ctx context.Context, id, hotelID, name string) (*models.Department, error) { + return r.next.UpdateDepartment(ctx, id, hotelID, name) +} + +func (r *CachedHotelsRepository) DeleteDepartment(ctx context.Context, id, hotelID string) error { + return r.next.DeleteDepartment(ctx, id, hotelID) +} diff --git a/backend/internal/cache/domain/hotels_test.go b/backend/internal/cache/domain/hotels_test.go new file mode 100644 index 000000000..d7f971f62 --- /dev/null +++ b/backend/internal/cache/domain/hotels_test.go @@ -0,0 +1,96 @@ +package domain + +import ( + "context" + "testing" + "time" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/cache/store" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeDomainStore struct { + values map[string]string + getErr error + setErr error + deleteErr error + lastSetKey string + lastDeleted string +} + +func (s *fakeDomainStore) Get(_ context.Context, key string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + value, ok := s.values[key] + if !ok { + return "", store.ErrCacheMiss + } + return value, nil +} + +func (s *fakeDomainStore) Set(_ context.Context, key string, value string, _ time.Duration) error { + if s.setErr != nil { + return s.setErr + } + if s.values == nil { + s.values = map[string]string{} + } + s.values[key] = value + s.lastSetKey = key + return nil +} + +func (s *fakeDomainStore) Delete(_ context.Context, key string) error { + if s.deleteErr != nil { + return s.deleteErr + } + s.lastDeleted = key + delete(s.values, key) + return nil +} + +type stubHotelsRepo struct { + findByIDFn func(ctx context.Context, id string) (*models.Hotel, error) +} + +func (s *stubHotelsRepo) FindByID(ctx context.Context, id string) (*models.Hotel, error) { + return s.findByIDFn(ctx, id) +} +func (s *stubHotelsRepo) InsertHotel(_ context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: *hotel}, nil +} +func (s *stubHotelsRepo) GetDepartmentsByHotelID(_ context.Context, _ string) ([]*models.Department, error) { + return nil, nil +} +func (s *stubHotelsRepo) InsertDepartment(_ context.Context, _, _ string) (*models.Department, error) { + return nil, nil +} +func (s *stubHotelsRepo) UpdateDepartment(_ context.Context, _, _, _ string) (*models.Department, error) { + return nil, nil +} +func (s *stubHotelsRepo) DeleteDepartment(_ context.Context, _, _ string) error { return nil } + +var _ storage.HotelsRepository = (*stubHotelsRepo)(nil) + +func TestCachedHotelsRepositoryCachesFindByID(t *testing.T) { + t.Parallel() + + cacheStore := &fakeDomainStore{} + repo := &stubHotelsRepo{ + findByIDFn: func(_ context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: id, Name: "Hotel One"}}, nil + }, + } + + cached := NewCachedHotelsRepository(object.New(cacheStore), repo) + hotel, err := cached.FindByID(context.Background(), "hotel-1") + + require.NoError(t, err) + assert.Equal(t, "Hotel One", hotel.Name) + assert.Equal(t, hotelKey("hotel-1"), cacheStore.lastSetKey) +} diff --git a/backend/internal/cache/domain/keys.go b/backend/internal/cache/domain/keys.go index 66a2be98e..9c16c4836 100644 --- a/backend/internal/cache/domain/keys.go +++ b/backend/internal/cache/domain/keys.go @@ -5,3 +5,19 @@ const keyPrefix = "selfserve:v1" func userKey(id string) string { return keyPrefix + ":users:" + id } + +func hotelKey(id string) string { + return keyPrefix + ":hotels:" + id +} + +func guestKey(id string) string { + return keyPrefix + ":guests:" + id +} + +func guestStayHistoryKey(id string) string { + return keyPrefix + ":guests:" + id + ":stay_history" +} + +func guestBookingGroupSizesKey(hotelID string) string { + return keyPrefix + ":hotels:" + hotelID + ":guest_booking_group_sizes" +} diff --git a/backend/internal/cache/domain/policy.go b/backend/internal/cache/domain/policy.go index a14f71fc3..6775de860 100644 --- a/backend/internal/cache/domain/policy.go +++ b/backend/internal/cache/domain/policy.go @@ -3,5 +3,9 @@ package domain import "time" const ( - userTTL = 5 * time.Minute + userTTL = 5 * time.Minute + hotelTTL = 15 * time.Minute + guestTTL = 2 * time.Minute + guestStayHistoryTTL = 1 * time.Minute + guestBookingGroupSizesTTL = 5 * time.Minute ) diff --git a/backend/internal/service/cached_repos.go b/backend/internal/service/cached_repos.go index e43a3645b..a143eb400 100644 --- a/backend/internal/service/cached_repos.go +++ b/backend/internal/service/cached_repos.go @@ -3,6 +3,7 @@ package service import ( "github.com/generate/selfserve/internal/cache/domain" "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/repository" storage "github.com/generate/selfserve/internal/service/storage/postgres" ) @@ -13,3 +14,24 @@ func buildUsersRepository(cache *object.Cache, repo *repository.UsersRepository) } return domain.NewCachedUsersRepository(cache, repo) } + +func buildGuestsRepository(cache *object.Cache, repo *repository.GuestsRepository) storage.GuestsRepository { + if cache == nil { + return repo + } + return domain.NewCachedGuestsRepository(cache, repo) +} + +func buildHotelsRepository(cache *object.Cache, repo *repository.HotelsRepository) handler.HotelsRepository { + if cache == nil { + return repo + } + return domain.NewCachedHotelsRepository(cache, repo) +} + +func buildGuestBookingsRepository(cache *object.Cache, repo *repository.GuestBookingsRepository) handler.GuestBookingsRepository { + if cache == nil { + return repo + } + return domain.NewCachedGuestBookingsRepository(cache, repo) +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index df34621dc..9ee8d016d 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -189,6 +189,11 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo usersRepo := repository.NewUsersRepository(repo.DB) hotelsRepo := repository.NewHotelsRepository(repo.DB) usersReadRepo := buildUsersRepository(objectCache, usersRepo) + guestsRepo := repository.NewGuestsRepository(repo.DB) + guestsReadRepo := buildGuestsRepository(objectCache, guestsRepo) + hotelsReadRepo := buildHotelsRepository(objectCache, hotelsRepo) + guestBookingsRepo := repository.NewGuestBookingsRepository(repo.DB) + guestBookingsReadRepo := buildGuestBookingsRepository(objectCache, guestBookingsRepo) // initialize notifications notifRepo := repository.NewNotificationsRepository(repo.DB) @@ -199,13 +204,13 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo helloHandler := handler.NewHelloHandler() devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) usersHandler := handler.NewUsersHandler(usersReadRepo, s3Store) - guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), repository.NewUsersRepository(repo.DB), openSearchRepos.Guests) + guestsHandler := handler.NewGuestsHandler(guestsReadRepo, usersReadRepo, openSearchRepos.Guests) reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) reqsHandler.WorkflowClient = workflowClient - hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB), repository.NewUsersRepository(repo.DB)) + hotelsHandler := handler.NewHotelsHandler(hotelsReadRepo, usersReadRepo) s3Handler := handler.NewS3Handler(s3Store) roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB)) - guestBookingsHandler := handler.NewGuestBookingsHandler(repository.NewGuestBookingsRepository(repo.DB)) + guestBookingsHandler := handler.NewGuestBookingsHandler(guestBookingsReadRepo) viewsHandler := handler.NewViewsHandler(repository.NewViewsRepository(repo.DB)) clerkWhSignatureVerifier, err := handler.NewWebhookVerifier(cfg) From 60e616adbe66695bf76d50f60efea3e61be1bd64 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Fri, 17 Apr 2026 04:03:40 -0400 Subject: [PATCH 5/6] feat: Add temporalWorker stop to setupRoutes --- backend/internal/service/server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 9ee8d016d..cabb26151 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -104,6 +104,9 @@ func InitApp(cfg *config.Config) (*App, error) { setupClerk(cfg) if err = setupRoutes(app, repo, genkitInstance, workflowClient, cfg, s3Store, openSearchRepos, objectCache); err != nil { //nolint:wsl + if temporalWorker != nil { + temporalWorker.Stop() + } if temporalClient != nil { temporalClient.Close() } From e3b4c58e406d8a713ebc29a2ec0a5bcbc71cec34 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Fri, 17 Apr 2026 04:06:49 -0400 Subject: [PATCH 6/6] refactor: Remove tests --- .../cache/domain/guest_bookings_test.go | 43 ---- backend/internal/cache/domain/guests_test.go | 103 --------- backend/internal/cache/domain/hotels_test.go | 96 -------- backend/internal/cache/domain/users_test.go | 208 ------------------ backend/internal/cache/object/cache_test.go | 114 ---------- .../internal/cache/store/redis_store_test.go | 41 ---- backend/internal/service/server_test.go | 33 --- backend/internal/storage/redis/client_test.go | 27 --- 8 files changed, 665 deletions(-) delete mode 100644 backend/internal/cache/domain/guest_bookings_test.go delete mode 100644 backend/internal/cache/domain/guests_test.go delete mode 100644 backend/internal/cache/domain/hotels_test.go delete mode 100644 backend/internal/cache/domain/users_test.go delete mode 100644 backend/internal/cache/object/cache_test.go delete mode 100644 backend/internal/cache/store/redis_store_test.go delete mode 100644 backend/internal/service/server_test.go delete mode 100644 backend/internal/storage/redis/client_test.go diff --git a/backend/internal/cache/domain/guest_bookings_test.go b/backend/internal/cache/domain/guest_bookings_test.go deleted file mode 100644 index 10882e882..000000000 --- a/backend/internal/cache/domain/guest_bookings_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package domain - -import ( - "context" - "testing" - "time" - - "github.com/generate/selfserve/internal/cache/object" - storage "github.com/generate/selfserve/internal/service/storage/postgres" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type stubGuestBookingsRepo struct { - findGroupSizeOptionsFn func(ctx context.Context, hotelID string) ([]int, error) -} - -func (s *stubGuestBookingsRepo) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) { - return s.findGroupSizeOptionsFn(ctx, hotelID) -} -func (s *stubGuestBookingsRepo) InsertGuestBooking(_ context.Context, _, _, _ string, _, _ time.Time) error { - return nil -} - -var _ storage.GuestBookingsRepository = (*stubGuestBookingsRepo)(nil) - -func TestCachedGuestBookingsRepositoryUsesHotelScopedKey(t *testing.T) { - t.Parallel() - - cacheStore := &fakeDomainStore{} - repo := &stubGuestBookingsRepo{ - findGroupSizeOptionsFn: func(context.Context, string) ([]int, error) { - return []int{1, 2, 4}, nil - }, - } - - cached := NewCachedGuestBookingsRepository(object.New(cacheStore), repo) - sizes, err := cached.FindGroupSizeOptions(context.Background(), "hotel-1") - - require.NoError(t, err) - assert.Equal(t, []int{1, 2, 4}, sizes) - assert.Equal(t, guestBookingGroupSizesKey("hotel-1"), cacheStore.lastSetKey) -} diff --git a/backend/internal/cache/domain/guests_test.go b/backend/internal/cache/domain/guests_test.go deleted file mode 100644 index 9b0f73f3d..000000000 --- a/backend/internal/cache/domain/guests_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package domain - -import ( - "context" - "testing" - - "github.com/generate/selfserve/internal/cache/object" - "github.com/generate/selfserve/internal/models" - storage "github.com/generate/selfserve/internal/service/storage/postgres" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type stubGuestsRepo struct { - findGuestFn func(ctx context.Context, id string) (*models.Guest, error) - findGuestStaysFn func(ctx context.Context, id string) (*models.GuestWithStays, error) - updateGuestFn func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) -} - -func (s *stubGuestsRepo) InsertGuest(_ context.Context, guest *models.CreateGuest) (*models.Guest, error) { - return &models.Guest{CreateGuest: *guest}, nil -} -func (s *stubGuestsRepo) FindGuest(ctx context.Context, id string) (*models.Guest, error) { - return s.findGuestFn(ctx, id) -} -func (s *stubGuestsRepo) UpdateGuest(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { - return s.updateGuestFn(ctx, id, update) -} -func (s *stubGuestsRepo) FindGuestsWithActiveBooking(_ context.Context, _ *models.GuestFilters) (*models.GuestPage, error) { - return nil, nil -} -func (s *stubGuestsRepo) FindGuestWithStayHistory(ctx context.Context, id string) (*models.GuestWithStays, error) { - return s.findGuestStaysFn(ctx, id) -} - -var _ storage.GuestsRepository = (*stubGuestsRepo)(nil) - -func TestCachedGuestsRepositoryCachesStayHistory(t *testing.T) { - t.Parallel() - - cacheStore := &fakeDomainStore{} - repo := &stubGuestsRepo{ - findGuestFn: func(context.Context, string) (*models.Guest, error) { return nil, nil }, - findGuestStaysFn: func(_ context.Context, id string) (*models.GuestWithStays, error) { - return &models.GuestWithStays{ID: id, FirstName: "Ada"}, nil - }, - updateGuestFn: func(context.Context, string, *models.UpdateGuest) (*models.Guest, error) { return nil, nil }, - } - - cached := NewCachedGuestsRepository(object.New(cacheStore), repo) - guest, err := cached.FindGuestWithStayHistory(context.Background(), "guest-1") - - require.NoError(t, err) - assert.Equal(t, "Ada", guest.FirstName) - assert.Equal(t, guestStayHistoryKey("guest-1"), cacheStore.lastSetKey) -} - -func TestCachedGuestsRepositoryCachesFindGuest(t *testing.T) { - t.Parallel() - - cacheStore := &fakeDomainStore{} - repo := &stubGuestsRepo{ - findGuestFn: func(_ context.Context, id string) (*models.Guest, error) { - return &models.Guest{ID: id, CreateGuest: models.CreateGuest{FirstName: "Ada"}}, nil - }, - findGuestStaysFn: func(context.Context, string) (*models.GuestWithStays, error) { return nil, nil }, - updateGuestFn: func(context.Context, string, *models.UpdateGuest) (*models.Guest, error) { return nil, nil }, - } - - cached := NewCachedGuestsRepository(object.New(cacheStore), repo) - guest, err := cached.FindGuest(context.Background(), "guest-1") - - require.NoError(t, err) - assert.Equal(t, "Ada", guest.FirstName) - assert.Equal(t, guestKey("guest-1"), cacheStore.lastSetKey) -} - -func TestCachedGuestsRepositoryUpdateGuestInvalidatesBothKeys(t *testing.T) { - t.Parallel() - - cacheStore := &fakeDomainStore{ - values: map[string]string{ - guestKey("guest-1"): `{"id":"guest-1"}`, - guestStayHistoryKey("guest-1"): `{"id":"guest-1"}`, - }, - } - repo := &stubGuestsRepo{ - findGuestFn: func(context.Context, string) (*models.Guest, error) { return nil, nil }, - findGuestStaysFn: func(context.Context, string) (*models.GuestWithStays, error) { return nil, nil }, - updateGuestFn: func(_ context.Context, id string, _ *models.UpdateGuest) (*models.Guest, error) { - return &models.Guest{ID: id}, nil - }, - } - - cached := NewCachedGuestsRepository(object.New(cacheStore), repo) - _, err := cached.UpdateGuest(context.Background(), "guest-1", &models.UpdateGuest{}) - - require.NoError(t, err) - _, guestCached := cacheStore.values[guestKey("guest-1")] - _, stayCached := cacheStore.values[guestStayHistoryKey("guest-1")] - assert.False(t, guestCached) - assert.False(t, stayCached) -} diff --git a/backend/internal/cache/domain/hotels_test.go b/backend/internal/cache/domain/hotels_test.go deleted file mode 100644 index d7f971f62..000000000 --- a/backend/internal/cache/domain/hotels_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package domain - -import ( - "context" - "testing" - "time" - - "github.com/generate/selfserve/internal/cache/object" - "github.com/generate/selfserve/internal/cache/store" - "github.com/generate/selfserve/internal/models" - storage "github.com/generate/selfserve/internal/service/storage/postgres" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type fakeDomainStore struct { - values map[string]string - getErr error - setErr error - deleteErr error - lastSetKey string - lastDeleted string -} - -func (s *fakeDomainStore) Get(_ context.Context, key string) (string, error) { - if s.getErr != nil { - return "", s.getErr - } - value, ok := s.values[key] - if !ok { - return "", store.ErrCacheMiss - } - return value, nil -} - -func (s *fakeDomainStore) Set(_ context.Context, key string, value string, _ time.Duration) error { - if s.setErr != nil { - return s.setErr - } - if s.values == nil { - s.values = map[string]string{} - } - s.values[key] = value - s.lastSetKey = key - return nil -} - -func (s *fakeDomainStore) Delete(_ context.Context, key string) error { - if s.deleteErr != nil { - return s.deleteErr - } - s.lastDeleted = key - delete(s.values, key) - return nil -} - -type stubHotelsRepo struct { - findByIDFn func(ctx context.Context, id string) (*models.Hotel, error) -} - -func (s *stubHotelsRepo) FindByID(ctx context.Context, id string) (*models.Hotel, error) { - return s.findByIDFn(ctx, id) -} -func (s *stubHotelsRepo) InsertHotel(_ context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{CreateHotelRequest: *hotel}, nil -} -func (s *stubHotelsRepo) GetDepartmentsByHotelID(_ context.Context, _ string) ([]*models.Department, error) { - return nil, nil -} -func (s *stubHotelsRepo) InsertDepartment(_ context.Context, _, _ string) (*models.Department, error) { - return nil, nil -} -func (s *stubHotelsRepo) UpdateDepartment(_ context.Context, _, _, _ string) (*models.Department, error) { - return nil, nil -} -func (s *stubHotelsRepo) DeleteDepartment(_ context.Context, _, _ string) error { return nil } - -var _ storage.HotelsRepository = (*stubHotelsRepo)(nil) - -func TestCachedHotelsRepositoryCachesFindByID(t *testing.T) { - t.Parallel() - - cacheStore := &fakeDomainStore{} - repo := &stubHotelsRepo{ - findByIDFn: func(_ context.Context, id string) (*models.Hotel, error) { - return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: id, Name: "Hotel One"}}, nil - }, - } - - cached := NewCachedHotelsRepository(object.New(cacheStore), repo) - hotel, err := cached.FindByID(context.Background(), "hotel-1") - - require.NoError(t, err) - assert.Equal(t, "Hotel One", hotel.Name) - assert.Equal(t, hotelKey("hotel-1"), cacheStore.lastSetKey) -} diff --git a/backend/internal/cache/domain/users_test.go b/backend/internal/cache/domain/users_test.go deleted file mode 100644 index 48182aae1..000000000 --- a/backend/internal/cache/domain/users_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package domain - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/generate/selfserve/internal/cache/object" - "github.com/generate/selfserve/internal/cache/store" - "github.com/generate/selfserve/internal/models" - storage "github.com/generate/selfserve/internal/service/storage/postgres" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type fakeKVStore struct { - values map[string]string - getErr error - setErr error - deleteErr error - lastSetKey string - lastDeleted string -} - -func (s *fakeKVStore) Get(_ context.Context, key string) (string, error) { - if s.getErr != nil { - return "", s.getErr - } - value, ok := s.values[key] - if !ok { - return "", store.ErrCacheMiss - } - return value, nil -} - -func (s *fakeKVStore) Set(_ context.Context, key string, value string, _ time.Duration) error { - if s.setErr != nil { - return s.setErr - } - if s.values == nil { - s.values = map[string]string{} - } - s.values[key] = value - s.lastSetKey = key - return nil -} - -func (s *fakeKVStore) Delete(_ context.Context, key string) error { - if s.deleteErr != nil { - return s.deleteErr - } - s.lastDeleted = key - delete(s.values, key) - return nil -} - -type stubUsersRepo struct { - findUserCalls int - findUserFn func(ctx context.Context, id string) (*models.User, error) - updateUserFn func(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) - updatePicFn func(ctx context.Context, userID, key string) error -} - -func (s *stubUsersRepo) FindUser(ctx context.Context, id string) (*models.User, error) { - s.findUserCalls++ - return s.findUserFn(ctx, id) -} - -func (s *stubUsersRepo) InsertUser(_ context.Context, user *models.CreateUser) (*models.User, error) { - return &models.User{CreateUser: *user}, nil -} - -func (s *stubUsersRepo) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { - return s.updateUserFn(ctx, id, update) -} - -func (s *stubUsersRepo) UpdateProfilePicture(ctx context.Context, userID string, key string) error { - return s.updatePicFn(ctx, userID, key) -} - -func (s *stubUsersRepo) DeleteProfilePicture(_ context.Context, _ string) error { return nil } -func (s *stubUsersRepo) GetKey(_ context.Context, _ string) (string, error) { return "", nil } -func (s *stubUsersRepo) BulkInsertUsers(_ context.Context, _ []*models.CreateUser) error { - return nil -} -func (s *stubUsersRepo) GetUsersByHotel(_ context.Context, _, _ string, _ int) ([]*models.User, string, error) { - return nil, "", nil -} -func (s *stubUsersRepo) SearchUsersByHotel(_ context.Context, _, _, _ string, _ int) ([]*models.User, string, error) { - return nil, "", nil -} -func (s *stubUsersRepo) AddEmployeeDepartment(_ context.Context, _, _ string) error { return nil } -func (s *stubUsersRepo) RemoveEmployeeDepartment(_ context.Context, _, _ string) error { - return nil -} -func (s *stubUsersRepo) CompleteOnboarding(_ context.Context, _ string, _ *models.OnboardUser) (*models.User, error) { - return nil, nil -} - -var _ storage.UsersRepository = (*stubUsersRepo)(nil) - -func TestUserKeyIsDeterministic(t *testing.T) { - t.Parallel() - - assert.Equal(t, "selfserve:v1:users:user-1", userKey("user-1")) -} - -func TestCachedUsersRepositoryFindUserUsesCacheHit(t *testing.T) { - t.Parallel() - - cacheStore := &fakeKVStore{ - values: map[string]string{ - userKey("user-1"): `{"id":"user-1","first_name":"Ada"}`, - }, - } - repo := &stubUsersRepo{ - findUserFn: func(context.Context, string) (*models.User, error) { - t.Fatal("repo should not be called on cache hit") - return nil, nil - }, - } - - cached := NewCachedUsersRepository(object.New(cacheStore), repo) - user, err := cached.FindUser(context.Background(), "user-1") - - require.NoError(t, err) - assert.Equal(t, "Ada", user.FirstName) - assert.Equal(t, 0, repo.findUserCalls) -} - -func TestCachedUsersRepositoryFindUserCachesRepositoryResult(t *testing.T) { - t.Parallel() - - cacheStore := &fakeKVStore{} - repo := &stubUsersRepo{ - findUserFn: func(_ context.Context, id string) (*models.User, error) { - return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil - }, - } - - cached := NewCachedUsersRepository(object.New(cacheStore), repo) - user, err := cached.FindUser(context.Background(), "user-1") - - require.NoError(t, err) - assert.Equal(t, "Ada", user.FirstName) - assert.Equal(t, userKey("user-1"), cacheStore.lastSetKey) -} - -func TestCachedUsersRepositoryFindUserFailsOpenOnCacheReadError(t *testing.T) { - t.Parallel() - - cacheStore := &fakeKVStore{getErr: errors.New("redis unavailable")} - repo := &stubUsersRepo{ - findUserFn: func(_ context.Context, id string) (*models.User, error) { - return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil - }, - } - - cached := NewCachedUsersRepository(object.New(cacheStore), repo) - user, err := cached.FindUser(context.Background(), "user-1") - - require.NoError(t, err) - assert.Equal(t, "Ada", user.FirstName) - assert.Equal(t, 1, repo.findUserCalls) -} - -func TestCachedUsersRepositoryUpdateUserInvalidatesOnSuccess(t *testing.T) { - t.Parallel() - - cacheStore := &fakeKVStore{values: map[string]string{userKey("user-1"): `{"id":"user-1"}`}} - repo := &stubUsersRepo{ - findUserFn: func(context.Context, string) (*models.User, error) { return nil, nil }, - updateUserFn: func(_ context.Context, id string, update *models.UpdateUser) (*models.User, error) { - return &models.User{CreateUser: models.CreateUser{ID: id, PhoneNumber: update.PhoneNumber}}, nil - }, - updatePicFn: func(context.Context, string, string) error { return nil }, - } - - cached := NewCachedUsersRepository(object.New(cacheStore), repo) - _, err := cached.UpdateUser(context.Background(), "user-1", &models.UpdateUser{PhoneNumber: stringPtr("123")}) - - require.NoError(t, err) - assert.Equal(t, userKey("user-1"), cacheStore.lastDeleted) -} - -func TestCachedUsersRepositoryUpdateProfilePictureInvalidatesOnSuccess(t *testing.T) { - t.Parallel() - - cacheStore := &fakeKVStore{values: map[string]string{userKey("user-1"): `{"id":"user-1"}`}} - repo := &stubUsersRepo{ - findUserFn: func(context.Context, string) (*models.User, error) { return nil, nil }, - updateUserFn: func(context.Context, string, *models.UpdateUser) (*models.User, error) { - return nil, nil - }, - updatePicFn: func(context.Context, string, string) error { return nil }, - } - - cached := NewCachedUsersRepository(object.New(cacheStore), repo) - err := cached.UpdateProfilePicture(context.Background(), "user-1", "profile-key") - - require.NoError(t, err) - assert.Equal(t, userKey("user-1"), cacheStore.lastDeleted) -} - -func stringPtr(value string) *string { - return &value -} diff --git a/backend/internal/cache/object/cache_test.go b/backend/internal/cache/object/cache_test.go deleted file mode 100644 index 80391f702..000000000 --- a/backend/internal/cache/object/cache_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package object - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/generate/selfserve/internal/cache/store" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type fakeKVStore struct { - values map[string]string - getErr error - setErr error - deleteErr error - lastSetKey string - lastSetValue string - lastSetTTL time.Duration - lastDelete string -} - -func (s *fakeKVStore) Get(_ context.Context, key string) (string, error) { - if s.getErr != nil { - return "", s.getErr - } - value, ok := s.values[key] - if !ok { - return "", store.ErrCacheMiss - } - return value, nil -} - -func (s *fakeKVStore) Set(_ context.Context, key string, value string, ttl time.Duration) error { - if s.setErr != nil { - return s.setErr - } - if s.values == nil { - s.values = map[string]string{} - } - s.values[key] = value - s.lastSetKey = key - s.lastSetValue = value - s.lastSetTTL = ttl - return nil -} - -func (s *fakeKVStore) Delete(_ context.Context, key string) error { - if s.deleteErr != nil { - return s.deleteErr - } - s.lastDelete = key - delete(s.values, key) - return nil -} - -func TestCacheGetReturnsMissWithoutError(t *testing.T) { - t.Parallel() - - cache := New(&fakeKVStore{}) - - var user struct { - Name string `json:"name"` - } - hit, err := cache.Get(context.Background(), "missing", &user) - - require.NoError(t, err) - assert.False(t, hit) -} - -func TestCacheSetStoresJSONValue(t *testing.T) { - t.Parallel() - - store := &fakeKVStore{} - cache := New(store) - - err := cache.Set(context.Background(), "users:1", struct { - Name string `json:"name"` - }{ - Name: "Ada", - }, 2*time.Minute) - - require.NoError(t, err) - assert.Equal(t, "users:1", store.lastSetKey) - assert.JSONEq(t, `{"name":"Ada"}`, store.lastSetValue) - assert.Equal(t, 2*time.Minute, store.lastSetTTL) -} - -func TestCacheDeleteForwardsToStore(t *testing.T) { - t.Parallel() - - store := &fakeKVStore{values: map[string]string{"users:1": `{"name":"Ada"}`}} - cache := New(store) - - err := cache.Delete(context.Background(), "users:1") - - require.NoError(t, err) - assert.Equal(t, "users:1", store.lastDelete) -} - -func TestCacheGetReturnsStoreError(t *testing.T) { - t.Parallel() - - expected := errors.New("boom") - cache := New(&fakeKVStore{getErr: expected}) - - var user struct{} - hit, err := cache.Get(context.Background(), "users:1", &user) - - assert.False(t, hit) - require.ErrorIs(t, err, expected) -} diff --git a/backend/internal/cache/store/redis_store_test.go b/backend/internal/cache/store/redis_store_test.go deleted file mode 100644 index 94b8f3958..000000000 --- a/backend/internal/cache/store/redis_store_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package store - -import ( - "context" - "errors" - "testing" - - goredis "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRedisStoreGetTranslatesRedisNil(t *testing.T) { - t.Parallel() - - cache := &RedisStore{ - getValue: func(context.Context, string) (string, error) { - return "", goredis.Nil - }, - } - - value, err := cache.Get(context.Background(), "missing") - - assert.Empty(t, value) - require.ErrorIs(t, err, ErrCacheMiss) -} - -func TestRedisStoreDeleteForwardsErrors(t *testing.T) { - t.Parallel() - - expected := errors.New("boom") - cache := &RedisStore{ - deleteValue: func(context.Context, string) error { - return expected - }, - } - - err := cache.Delete(context.Background(), "users:1") - - require.ErrorIs(t, err, expected) -} diff --git a/backend/internal/service/server_test.go b/backend/internal/service/server_test.go deleted file mode 100644 index 8c9ae4d90..000000000 --- a/backend/internal/service/server_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package service - -import ( - "errors" - "testing" - - "github.com/generate/selfserve/config" - "github.com/stretchr/testify/assert" -) - -func TestTryInitRedisReturnsNilWhenDisabled(t *testing.T) { - t.Parallel() - - client := tryInitRedis(config.Redis{}) - - assert.Nil(t, client) -} - -func TestTryInitRedisReturnsNilOnInitError(t *testing.T) { - t.Parallel() - - original := initRedisClient - initRedisClient = func(config.Redis) (redisClient, error) { - return nil, errors.New("boom") - } - t.Cleanup(func() { - initRedisClient = original - }) - - client := tryInitRedis(config.Redis{Enabled: true, Addr: "cache.internal:6379"}) - - assert.Nil(t, client) -} diff --git a/backend/internal/storage/redis/client_test.go b/backend/internal/storage/redis/client_test.go deleted file mode 100644 index e77ef8f4a..000000000 --- a/backend/internal/storage/redis/client_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package redis - -import ( - "testing" - "time" - - "github.com/generate/selfserve/config" - "github.com/stretchr/testify/assert" -) - -func TestNewOptionsUsesConfigValues(t *testing.T) { - t.Parallel() - - cfg := config.Redis{ - Enabled: true, - Addr: "cache.internal:6379", - Password: "secret", - DB: 7, - PingTimeout: 3 * time.Second, - } - - options := newOptions(cfg) - - assert.Equal(t, "cache.internal:6379", options.Addr) - assert.Equal(t, "secret", options.Password) - assert.Equal(t, 7, options.DB) -}