From 28c3e641b0bd2464cb887b0e6dc3b208fe16c0ca Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Sat, 4 Apr 2026 18:16:07 -0400 Subject: [PATCH 1/3] feat: Add redis caching, ttl, on initial endpoints --- backend/config/config.go | 1 + backend/config/redis.go | 6 + backend/config/redis_test.go | 19 ++ backend/internal/cache/json_cache.go | 77 ++++++ backend/internal/cache/json_cache_test.go | 118 +++++++++ backend/internal/cache/readers.go | 226 +++++++++++++++++ backend/internal/cache/readers_test.go | 235 ++++++++++++++++++ backend/internal/service/cached_repos.go | 42 ++++ backend/internal/service/cached_repos_test.go | 76 ++++++ backend/internal/service/server.go | 28 ++- backend/internal/storage/redis/client.go | 24 +- backend/internal/storage/redis/client_test.go | 20 +- clients/web/src/tests/shared-exports.test.ts | 8 + 13 files changed, 854 insertions(+), 26 deletions(-) create mode 100644 backend/config/redis.go create mode 100644 backend/config/redis_test.go create mode 100644 backend/internal/cache/json_cache.go create mode 100644 backend/internal/cache/json_cache_test.go create mode 100644 backend/internal/cache/readers.go create mode 100644 backend/internal/cache/readers_test.go create mode 100644 backend/internal/service/cached_repos.go create mode 100644 backend/internal/service/cached_repos_test.go create mode 100644 clients/web/src/tests/shared-exports.test.ts diff --git a/backend/config/config.go b/backend/config/config.go index b1563c5a..a2ca94a3 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -6,5 +6,6 @@ type Config struct { LLM `env:",prefix=LLM_"` Clerk `env:",prefix=CLERK_"` S3 `env:",prefix=AWS_S3_"` + 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 00000000..2d50edb9 --- /dev/null +++ b/backend/config/redis.go @@ -0,0 +1,6 @@ +package config + +type Redis struct { + Addr string `env:"ADDR, default=localhost:6379"` + Password string `env:"PASSWORD"` +} diff --git a/backend/config/redis_test.go b/backend/config/redis_test.go new file mode 100644 index 00000000..d36b2ad1 --- /dev/null +++ b/backend/config/redis_test.go @@ -0,0 +1,19 @@ +package config + +import ( + "context" + "testing" + + "github.com/sethvargo/go-envconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedisConfigDefaults(t *testing.T) { + var cfg Redis + err := envconfig.Process(context.Background(), &cfg) + + require.NoError(t, err) + assert.Equal(t, "localhost:6379", cfg.Addr) + assert.Equal(t, "", cfg.Password) +} diff --git a/backend/internal/cache/json_cache.go b/backend/internal/cache/json_cache.go new file mode 100644 index 00000000..f240b9da --- /dev/null +++ b/backend/internal/cache/json_cache.go @@ -0,0 +1,77 @@ +package cache + +import ( + "context" + "encoding/json" + "errors" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +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 +} + +type JSONCache struct { + store KVStore +} + +type RedisStore struct { + client *goredis.Client +} + +func NewJSONCache(store KVStore) *JSONCache { + if store == nil { + return nil + } + return &JSONCache{store: store} +} + +func NewRedisStore(client *goredis.Client) *RedisStore { + if client == nil { + return nil + } + return &RedisStore{client: client} +} + +func (s *RedisStore) Get(ctx context.Context, key string) (string, error) { + value, err := s.client.Get(ctx, key).Result() + 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.client.Set(ctx, key, value, ttl).Err() +} + +func (c *JSONCache) GetJSON(ctx context.Context, key string, dest any) (bool, error) { + value, err := c.store.Get(ctx, key) + if errors.Is(err, 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 *JSONCache) SetJSON(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) +} + diff --git a/backend/internal/cache/json_cache_test.go b/backend/internal/cache/json_cache_test.go new file mode 100644 index 00000000..10d45082 --- /dev/null +++ b/backend/internal/cache/json_cache_test.go @@ -0,0 +1,118 @@ +package cache + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeKVStore struct { + values map[string]string + getErr error + setErr error + lastSetKey string + lastSetValue string + lastSetTTL time.Duration +} + +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 "", 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 TestJSONCache_GetJSONReturnsCachedValue(t *testing.T) { + t.Parallel() + + store := &fakeKVStore{ + values: map[string]string{ + "user:1": `{"name":"Ada","count":3}`, + }, + } + cache := NewJSONCache(store) + + var dest struct { + Name string `json:"name"` + Count int `json:"count"` + } + hit, err := cache.GetJSON(context.Background(), "user:1", &dest) + + require.NoError(t, err) + assert.True(t, hit) + assert.Equal(t, "Ada", dest.Name) + assert.Equal(t, 3, dest.Count) +} + +func TestJSONCache_GetJSONReturnsMissForMissingKey(t *testing.T) { + t.Parallel() + + cache := NewJSONCache(&fakeKVStore{}) + + var dest struct { + Name string `json:"name"` + } + hit, err := cache.GetJSON(context.Background(), "missing", &dest) + + require.NoError(t, err) + assert.False(t, hit) + assert.Equal(t, "", dest.Name) +} + +func TestJSONCache_SetJSONStoresSerializedValue(t *testing.T) { + t.Parallel() + + store := &fakeKVStore{} + cache := NewJSONCache(store) + + value := struct { + Name string `json:"name"` + Count int `json:"count"` + }{ + Name: "Ada", + Count: 3, + } + + err := cache.SetJSON(context.Background(), "user:1", value, 2*time.Minute) + + require.NoError(t, err) + assert.Equal(t, "user:1", store.lastSetKey) + assert.JSONEq(t, `{"name":"Ada","count":3}`, store.lastSetValue) + assert.Equal(t, 2*time.Minute, store.lastSetTTL) +} + +func TestJSONCache_GetJSONReturnsStoreError(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("boom") + cache := NewJSONCache(&fakeKVStore{getErr: expectedErr}) + + var dest struct{} + hit, err := cache.GetJSON(context.Background(), "user:1", &dest) + + assert.False(t, hit) + require.ErrorIs(t, err, expectedErr) +} + diff --git a/backend/internal/cache/readers.go b/backend/internal/cache/readers.go new file mode 100644 index 00000000..254e1571 --- /dev/null +++ b/backend/internal/cache/readers.go @@ -0,0 +1,226 @@ +package cache + +import ( + "context" + "time" + + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +const keyPrefix = "selfserve:v1" + +type UsersReader interface { + FindUser(ctx context.Context, id string) (*models.User, error) +} + +type HotelsReader interface { + FindByID(ctx context.Context, id string) (*models.Hotel, error) +} + +type GuestBookingsReader interface { + FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) +} + +type CachedUsersRepository struct { + cache *JSONCache + next UsersReader + ttl time.Duration +} + +type CachedHotelsRepository struct { + cache *JSONCache + next HotelsReader + ttl time.Duration +} + +type CachedGuestsRepository struct { + cache *JSONCache + next storage.GuestsRepository + guestTTL time.Duration + guestStaysTTL time.Duration +} + +type CachedGuestBookingsRepository struct { + cache *JSONCache + next GuestBookingsReader + ttl time.Duration +} + +func NewCachedUsersRepository(cache *JSONCache, next UsersReader, ttl time.Duration) *CachedUsersRepository { + return &CachedUsersRepository{cache: cache, next: next, ttl: ttl} +} + +func NewCachedHotelsRepository(cache *JSONCache, next HotelsReader, ttl time.Duration) *CachedHotelsRepository { + return &CachedHotelsRepository{cache: cache, next: next, ttl: ttl} +} + +func NewCachedGuestsRepository(cache *JSONCache, next storage.GuestsRepository, guestTTL, guestStaysTTL time.Duration) *CachedGuestsRepository { + return &CachedGuestsRepository{cache: cache, next: next, guestTTL: guestTTL, guestStaysTTL: guestStaysTTL} +} + +func NewCachedGuestBookingsRepository(cache *JSONCache, next GuestBookingsReader, ttl time.Duration) *CachedGuestBookingsRepository { + return &CachedGuestBookingsRepository{cache: cache, next: next, ttl: ttl} +} + +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.GetJSON(ctx, key, &cached); err == nil && hit { + return &cached, nil + } + + user, err := r.next.FindUser(ctx, id) + if err != nil { + return nil, err + } + + _ = r.cache.SetJSON(ctx, key, user, r.ttl) + return user, nil +} + +func (r *CachedUsersRepository) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { + repo, ok := r.next.(interface { + InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) + }) + if !ok { + return nil, nil + } + return repo.InsertUser(ctx, user) +} + +func (r *CachedUsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { + repo, ok := r.next.(interface { + UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) + }) + if !ok { + return nil, nil + } + return repo.UpdateUser(ctx, id, update) +} + +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.GetJSON(ctx, key, &cached); err == nil && hit { + return &cached, nil + } + + hotel, err := r.next.FindByID(ctx, id) + if err != nil { + return nil, err + } + + _ = r.cache.SetJSON(ctx, key, hotel, r.ttl) + return hotel, nil +} + +func (r *CachedHotelsRepository) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + repo, ok := r.next.(interface { + InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) + }) + if !ok { + return nil, nil + } + return repo.InsertHotel(ctx, hotel) +} + +func (r *CachedGuestsRepository) InsertGuest(ctx context.Context, guest *models.CreateGuest) (*models.Guest, error) { + return r.next.InsertGuest(ctx, guest) +} + +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.GetJSON(ctx, key, &cached); err == nil && hit { + return &cached, nil + } + + guest, err := r.next.FindGuest(ctx, id) + if err != nil { + return nil, err + } + + _ = r.cache.SetJSON(ctx, key, guest, r.guestTTL) + return guest, nil +} + +func (r *CachedGuestsRepository) UpdateGuest(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + return r.next.UpdateGuest(ctx, id, update) +} + +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 := guestStaysKey(id) + + var cached models.GuestWithStays + if hit, err := r.cache.GetJSON(ctx, key, &cached); err == nil && hit { + return &cached, nil + } + + guest, err := r.next.FindGuestWithStayHistory(ctx, id) + if err != nil { + return nil, err + } + + _ = r.cache.SetJSON(ctx, key, guest, r.guestStaysTTL) + return guest, nil +} + +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.GetJSON(ctx, key, &cached); err == nil && hit { + return cached, nil + } + + sizes, err := r.next.FindGroupSizeOptions(ctx, hotelID) + if err != nil { + return nil, err + } + + _ = r.cache.SetJSON(ctx, key, sizes, r.ttl) + return sizes, nil +} + +func userKey(id string) string { + return keyPrefix + ":user:" + id +} + +func hotelKey(id string) string { + return keyPrefix + ":hotel:" + id +} + +func guestKey(id string) string { + return keyPrefix + ":guest:" + id +} + +func guestStaysKey(id string) string { + return keyPrefix + ":guest_stays:" + id +} + +func guestBookingGroupSizesKey(hotelID string) string { + return keyPrefix + ":guest_booking_group_sizes:" + hotelID +} diff --git a/backend/internal/cache/readers_test.go b/backend/internal/cache/readers_test.go new file mode 100644 index 00000000..ee81d1d9 --- /dev/null +++ b/backend/internal/cache/readers_test.go @@ -0,0 +1,235 @@ +package cache + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/generate/selfserve/internal/errs" + "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 stubUsersRepo struct { + findUserCalls int + findUserFn func(ctx context.Context, id string) (*models.User, 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(_ context.Context, id string, update *models.UpdateUser) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, PhoneNumber: update.PhoneNumber}}, nil +} + +type stubHotelsRepo struct { + findByIDCalls int + findByIDFn func(ctx context.Context, id string) (*models.Hotel, error) +} + +func (s *stubHotelsRepo) FindByID(ctx context.Context, id string) (*models.Hotel, error) { + s.findByIDCalls++ + return s.findByIDFn(ctx, id) +} + +func (s *stubHotelsRepo) InsertHotel(_ context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: *hotel}, nil +} + +type stubGuestsRepo struct { + findGuestCalls int + findGuestStaysCalls int + findGuestFn func(ctx context.Context, id string) (*models.Guest, error) + findGuestStaysFn func(ctx context.Context, id string) (*models.GuestWithStays, 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) { + s.findGuestCalls++ + return s.findGuestFn(ctx, id) +} + +func (s *stubGuestsRepo) UpdateGuest(_ context.Context, id string, _ *models.UpdateGuest) (*models.Guest, error) { + return &models.Guest{ID: id}, nil +} + +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) { + s.findGuestStaysCalls++ + return s.findGuestStaysFn(ctx, id) +} + +type stubGuestBookingsRepo struct { + findGroupSizeCalls int + findGroupSizeFn func(ctx context.Context, hotelID string) ([]int, error) +} + +func (s *stubGuestBookingsRepo) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) { + s.findGroupSizeCalls++ + return s.findGroupSizeFn(ctx, hotelID) +} + +var _ storage.GuestsRepository = (*stubGuestsRepo)(nil) + +func TestCachedUsersRepository_FindUserUsesCacheHit(t *testing.T) { + t.Parallel() + + store := &fakeKVStore{ + values: map[string]string{ + "selfserve:v1:user:user-1": `{"id":"user-1","first_name":"Ada"}`, + }, + } + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, _ string) (*models.User, error) { + t.Fatal("repository should not be called on cache hit") + return nil, nil + }, + } + + cached := NewCachedUsersRepository(NewJSONCache(store), repo, 5*time.Minute) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, "user-1", user.ID) + assert.Equal(t, "Ada", user.FirstName) + assert.Equal(t, 0, repo.findUserCalls) +} + +func TestCachedUsersRepository_FindUserCachesRepositoryResult(t *testing.T) { + t.Parallel() + + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil + }, + } + store := &fakeKVStore{} + + cached := NewCachedUsersRepository(NewJSONCache(store), repo, 5*time.Minute) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, 1, repo.findUserCalls) + assert.Equal(t, "Ada", user.FirstName) + assert.Equal(t, "selfserve:v1:user:user-1", store.lastSetKey) + assert.Equal(t, 5*time.Minute, store.lastSetTTL) + assert.Contains(t, store.lastSetValue, `"id":"user-1"`) + assert.Contains(t, store.lastSetValue, `"first_name":"Ada"`) +} + +func TestCachedUsersRepository_FindUserFallsBackWhenCacheReadFails(t *testing.T) { + t.Parallel() + + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil + }, + } + store := &fakeKVStore{getErr: errors.New("redis unavailable")} + + cached := NewCachedUsersRepository(NewJSONCache(store), repo, 5*time.Minute) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, 1, repo.findUserCalls) + assert.Equal(t, "Ada", user.FirstName) +} + +func TestCachedUsersRepository_FindUserDoesNotCacheNotFound(t *testing.T) { + t.Parallel() + + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, _ string) (*models.User, error) { + return nil, errs.ErrNotFoundInDB + }, + } + store := &fakeKVStore{} + + cached := NewCachedUsersRepository(NewJSONCache(store), repo, 5*time.Minute) + user, err := cached.FindUser(context.Background(), "user-1") + + require.ErrorIs(t, err, errs.ErrNotFoundInDB) + assert.Nil(t, user) + assert.Equal(t, "", store.lastSetKey) +} + +func TestCachedHotelsRepository_FindByIDUsesCacheKey(t *testing.T) { + t.Parallel() + + repo := &stubHotelsRepo{ + findByIDFn: func(_ context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{ID: id, CreateHotelRequest: models.CreateHotelRequest{Name: "Hotel One"}}, nil + }, + } + store := &fakeKVStore{} + + cached := NewCachedHotelsRepository(NewJSONCache(store), repo, 15*time.Minute) + hotel, err := cached.FindByID(context.Background(), "hotel-1") + + require.NoError(t, err) + assert.Equal(t, "hotel-1", hotel.ID) + assert.Equal(t, "selfserve:v1:hotel:hotel-1", store.lastSetKey) +} + +func TestCachedGuestsRepository_FindGuestWithStayHistoryCachesResult(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + repo := &stubGuestsRepo{ + findGuestFn: func(_ context.Context, id string) (*models.Guest, error) { + return &models.Guest{ID: id}, nil + }, + findGuestStaysFn: func(_ context.Context, id string) (*models.GuestWithStays, error) { + return &models.GuestWithStays{ + ID: id, + FirstName: "Ada", + CurrentStays: []models.Stay{ + {ArrivalDate: now, DepartureDate: now.Add(24 * time.Hour), RoomNumber: 404, Status: models.BookingStatusActive}, + }, + }, nil + }, + } + store := &fakeKVStore{} + + cached := NewCachedGuestsRepository(NewJSONCache(store), repo, 2*time.Minute, 1*time.Minute) + result, err := cached.FindGuestWithStayHistory(context.Background(), "guest-1") + + require.NoError(t, err) + assert.Equal(t, 1, repo.findGuestStaysCalls) + assert.Equal(t, "selfserve:v1:guest_stays:guest-1", store.lastSetKey) + assert.Equal(t, 1*time.Minute, store.lastSetTTL) + assert.Equal(t, "Ada", result.FirstName) +} + +func TestCachedGuestBookingsRepository_FindGroupSizeOptionsUsesHotelScopedKey(t *testing.T) { + t.Parallel() + + repo := &stubGuestBookingsRepo{ + findGroupSizeFn: func(_ context.Context, hotelID string) ([]int, error) { + return []int{1, 2, 4}, nil + }, + } + store := &fakeKVStore{} + + cached := NewCachedGuestBookingsRepository(NewJSONCache(store), repo, 5*time.Minute) + sizes, err := cached.FindGroupSizeOptions(context.Background(), "hotel-1") + + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 4}, sizes) + assert.Equal(t, "selfserve:v1:guest_booking_group_sizes:hotel-1", store.lastSetKey) +} diff --git a/backend/internal/service/cached_repos.go b/backend/internal/service/cached_repos.go new file mode 100644 index 00000000..e547152d --- /dev/null +++ b/backend/internal/service/cached_repos.go @@ -0,0 +1,42 @@ +package service + +import ( + "time" + + "github.com/generate/selfserve/internal/cache" + "github.com/generate/selfserve/internal/handler" + "github.com/generate/selfserve/internal/repository" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +func buildUsersRepository(jsonCache *cache.JSONCache, repo *repository.UsersRepository) handler.UsersRepository { + if jsonCache == nil { + return repo + } + return cache.NewCachedUsersRepository(jsonCache, repo, 5*time.Minute) +} + +func buildGuestsRepository(jsonCache *cache.JSONCache, repo *repository.GuestsRepository) storage.GuestsRepository { + if jsonCache == nil { + return repo + } + return cache.NewCachedGuestsRepository(jsonCache, repo, 2*time.Minute, time.Minute) +} + +func buildHotelsRepository(jsonCache *cache.JSONCache, repo *repository.HotelsRepository) handler.HotelsRepository { + if jsonCache == nil { + return repo + } + return cache.NewCachedHotelsRepository(jsonCache, repo, 15*time.Minute) +} + +func buildGuestBookingsRepository( + jsonCache *cache.JSONCache, + repo *repository.GuestBookingsRepository, +) handler.GuestBookingsRepository { + if jsonCache == nil { + return repo + } + return cache.NewCachedGuestBookingsRepository(jsonCache, repo, 5*time.Minute) +} + diff --git a/backend/internal/service/cached_repos_test.go b/backend/internal/service/cached_repos_test.go new file mode 100644 index 00000000..e12ba806 --- /dev/null +++ b/backend/internal/service/cached_repos_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/generate/selfserve/internal/cache" + "github.com/generate/selfserve/internal/repository" +) + +type noopStore struct{} + +func (noopStore) Get(_ context.Context, _ string) (string, error) { + return "", cache.ErrCacheMiss +} + +func (noopStore) Set(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} + +func TestBuildUsersRepositoryUsesCacheWhenAvailable(t *testing.T) { + t.Parallel() + + repo := &repository.UsersRepository{} + cachedRepo := buildUsersRepository(cache.NewJSONCache(noopStore{}), repo) + + if _, ok := cachedRepo.(*cache.CachedUsersRepository); !ok { + t.Fatalf("expected cached users repository, got %T", cachedRepo) + } +} + +func TestBuildUsersRepositoryFallsBackWithoutCache(t *testing.T) { + t.Parallel() + + repo := &repository.UsersRepository{} + plainRepo := buildUsersRepository(nil, repo) + + if plainRepo != repo { + t.Fatalf("expected original users repository, got %T", plainRepo) + } +} + +func TestBuildGuestsRepositoryUsesCacheWhenAvailable(t *testing.T) { + t.Parallel() + + repo := &repository.GuestsRepository{} + cachedRepo := buildGuestsRepository(cache.NewJSONCache(noopStore{}), repo) + + if _, ok := cachedRepo.(*cache.CachedGuestsRepository); !ok { + t.Fatalf("expected cached guests repository, got %T", cachedRepo) + } +} + +func TestBuildHotelsRepositoryUsesCacheWhenAvailable(t *testing.T) { + t.Parallel() + + repo := &repository.HotelsRepository{} + cachedRepo := buildHotelsRepository(cache.NewJSONCache(noopStore{}), repo) + + if _, ok := cachedRepo.(*cache.CachedHotelsRepository); !ok { + t.Fatalf("expected cached hotels repository, got %T", cachedRepo) + } +} + +func TestBuildGuestBookingsRepositoryUsesCacheWhenAvailable(t *testing.T) { + t.Parallel() + + repo := &repository.GuestBookingsRepository{} + cachedRepo := buildGuestBookingsRepository(cache.NewJSONCache(noopStore{}), repo) + + if _, ok := cachedRepo.(*cache.CachedGuestBookingsRepository); !ok { + t.Fatalf("expected cached guest bookings repository, got %T", cachedRepo) + } +} + diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 2a877c58..b4ffba0b 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -11,6 +11,7 @@ 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" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/repository" @@ -51,7 +52,8 @@ func InitApp(cfg *config.Config) (*App, error) { return nil, err } - redisClient := tryInitRedis() + redisClient := tryInitRedis(cfg.Redis) + jsonCache := cache.NewJSONCache(cache.NewRedisStore(redisClient)) s3Store, err := s3storage.NewS3Storage(cfg.S3) if err != nil { @@ -68,7 +70,7 @@ func InitApp(cfg *config.Config) (*App, error) { app := setupApp() setupClerk(cfg) - if err = setupRoutes(app, repo, genkitInstance, cfg, s3Store, openSearchRepos); err != nil { //nolint:wsl + if err = setupRoutes(app, repo, genkitInstance, cfg, s3Store, openSearchRepos, jsonCache); err != nil { //nolint:wsl if e := repo.Close(); e != nil { return nil, errors.Join(err, e) } @@ -102,8 +104,8 @@ func tryInitOpenSearchRepositories(cfg *config.Config) openSearchRepositories { } } -func tryInitRedis() *goredis.Client { - redisClient, err := redis.InitRedis() +func tryInitRedis(cfg config.Redis) *goredis.Client { + redisClient, err := redis.InitRedis(cfg) if err != nil { log.Printf("Warning: Redis not available: %v", err) return nil @@ -112,7 +114,7 @@ func tryInitRedis() *goredis.Client { } func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflows.GenkitService, - cfg *config.Config, s3Store *s3storage.Storage, openSearchRepos openSearchRepositories) error { + cfg *config.Config, s3Store *s3storage.Storage, openSearchRepos openSearchRepositories, jsonCache *cache.JSONCache) error { // Swagger documentation app.Get("/swagger/*", handler.ServeSwagger) @@ -128,22 +130,30 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // initialize users repo usersRepo := repository.NewUsersRepository(repo.DB) + usersReadRepo := buildUsersRepository(jsonCache, usersRepo) // initialize notifications notifRepo := repository.NewNotificationsRepository(repo.DB) notifService := notificationssvc.NewService(notifRepo) notifHandler := handler.NewNotificationsHandler(notifRepo) + guestsRepo := repository.NewGuestsRepository(repo.DB) + guestsReadRepo := buildGuestsRepository(jsonCache, guestsRepo) + hotelsRepo := repository.NewHotelsRepository(repo.DB) + hotelsReadRepo := buildHotelsRepository(jsonCache, hotelsRepo) + guestBookingsRepo := repository.NewGuestBookingsRepository(repo.DB) + guestBookingsReadRepo := buildGuestBookingsRepository(jsonCache, guestBookingsRepo) + // initialize handler(s) helloHandler := handler.NewHelloHandler() devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) - usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB)) - guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), openSearchRepos.Guests) + usersHandler := handler.NewUsersHandler(usersReadRepo) + guestsHandler := handler.NewGuestsHandler(guestsReadRepo, openSearchRepos.Guests) reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) - hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB)) + hotelsHandler := handler.NewHotelsHandler(hotelsReadRepo) s3Handler := handler.NewS3Handler(s3Store) roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB)) - guestBookingsHandler := handler.NewGuestBookingsHandler(repository.NewGuestBookingsRepository(repo.DB)) + guestBookingsHandler := handler.NewGuestBookingsHandler(guestBookingsReadRepo) clerkWhSignatureVerifier, err := handler.NewWebhookVerifier(cfg) if err != nil { diff --git a/backend/internal/storage/redis/client.go b/backend/internal/storage/redis/client.go index bb749b03..b0600e67 100644 --- a/backend/internal/storage/redis/client.go +++ b/backend/internal/storage/redis/client.go @@ -3,18 +3,14 @@ package redis import ( "context" "fmt" - "os" + "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, - }) +func InitRedis(cfg config.Redis) (*redis.Client, error) { + client := redis.NewClient(newOptions(cfg)) ctx := context.Background() if err := client.Ping(ctx).Err(); err != nil { @@ -32,14 +28,10 @@ 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: 0, } - return addr -} - -func getRedisPassword() string { - return os.Getenv("REDIS_PASSWORD") } diff --git a/backend/internal/storage/redis/client_test.go b/backend/internal/storage/redis/client_test.go index a171e6ea..48680c5e 100644 --- a/backend/internal/storage/redis/client_test.go +++ b/backend/internal/storage/redis/client_test.go @@ -3,10 +3,28 @@ package redis import ( "context" "testing" + + "github.com/generate/selfserve/config" + "github.com/stretchr/testify/assert" ) +func TestInitRedisUsesConfigValues(t *testing.T) { + t.Parallel() + + cfg := config.Redis{ + Addr: "cache.internal:6379", + Password: "secret", + } + + options := newOptions(cfg) + + assert.Equal(t, "cache.internal:6379", options.Addr) + assert.Equal(t, "secret", options.Password) + assert.Equal(t, 0, options.DB) +} + func TestRedisConnection(t *testing.T) { - client, err := InitRedis() + client, err := InitRedis(config.Redis{Addr: "localhost:6379"}) if err != nil { t.Skipf("Skipping test: Redis not available: %v", err) } diff --git a/clients/web/src/tests/shared-exports.test.ts b/clients/web/src/tests/shared-exports.test.ts new file mode 100644 index 00000000..5b9ecc47 --- /dev/null +++ b/clients/web/src/tests/shared-exports.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import * as shared from "@shared"; + +describe("shared package exports", () => { + it("exports the guest booking group sizes hook", () => { + expect(shared.useGetGuestBookingsGroupSizes).toBeTypeOf("function"); + }); +}); From b13f4f026481d470c6d97250e9f2734d315853f4 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Sat, 4 Apr 2026 18:36:10 -0400 Subject: [PATCH 2/3] style: Fix styling --- backend/cmd/server/main.go | 4 +- backend/internal/cache/json_cache.go | 27 +++++- backend/internal/cache/json_cache_test.go | 1 - backend/internal/cache/readers.go | 85 ++++++++++--------- backend/internal/cache/readers_test.go | 48 +++++++++++ backend/internal/service/cached_repos.go | 1 - backend/internal/service/cached_repos_test.go | 76 ----------------- backend/internal/service/server.go | 23 +++-- 8 files changed, 134 insertions(+), 131 deletions(-) delete mode 100644 backend/internal/service/cached_repos_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5014f25d..961cdffe 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -50,8 +50,8 @@ func main() { } defer func() { - if err := app.Repo.Close(); err != nil { - panic(fmt.Sprintf("failed to close repo: %v", err)) + if err := app.Close(); err != nil { + panic(fmt.Sprintf("failed to close app resources: %v", err)) } }() diff --git a/backend/internal/cache/json_cache.go b/backend/internal/cache/json_cache.go index f240b9da..d84f9e9c 100644 --- a/backend/internal/cache/json_cache.go +++ b/backend/internal/cache/json_cache.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log/slog" "time" goredis "github.com/redis/go-redis/v9" @@ -17,18 +18,25 @@ type KVStore interface { } type JSONCache struct { - store KVStore + store KVStore + logger *slog.Logger } type RedisStore struct { client *goredis.Client } -func NewJSONCache(store KVStore) *JSONCache { +func NewJSONCache(store KVStore, logger ...*slog.Logger) *JSONCache { if store == nil { return nil } - return &JSONCache{store: store} + + resolvedLogger := slog.Default() + if len(logger) > 0 && logger[0] != nil { + resolvedLogger = logger[0] + } + + return &JSONCache{store: store, logger: resolvedLogger} } func NewRedisStore(client *goredis.Client) *RedisStore { @@ -75,3 +83,16 @@ func (c *JSONCache) SetJSON(ctx context.Context, key string, value any, ttl time return c.store.Set(ctx, key, string(encoded), ttl) } +func (c *JSONCache) WarnReadError(key string, err error) { + if c == nil || err == nil { + return + } + c.logger.Warn("redis cache read failed", "key", key, "err", err) +} + +func (c *JSONCache) WarnWriteError(key string, err error) { + if c == nil || err == nil { + return + } + c.logger.Warn("redis cache write failed", "key", key, "err", err) +} diff --git a/backend/internal/cache/json_cache_test.go b/backend/internal/cache/json_cache_test.go index 10d45082..2b8cc4e0 100644 --- a/backend/internal/cache/json_cache_test.go +++ b/backend/internal/cache/json_cache_test.go @@ -115,4 +115,3 @@ func TestJSONCache_GetJSONReturnsStoreError(t *testing.T) { assert.False(t, hit) require.ErrorIs(t, err, expectedErr) } - diff --git a/backend/internal/cache/readers.go b/backend/internal/cache/readers.go index 254e1571..b4463bb4 100644 --- a/backend/internal/cache/readers.go +++ b/backend/internal/cache/readers.go @@ -10,12 +10,15 @@ import ( const keyPrefix = "selfserve:v1" -type UsersReader interface { +type UsersRepository interface { FindUser(ctx context.Context, id string) (*models.User, error) + InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) + UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) } -type HotelsReader interface { +type HotelsRepository interface { FindByID(ctx context.Context, id string) (*models.Hotel, error) + InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) } type GuestBookingsReader interface { @@ -24,20 +27,20 @@ type GuestBookingsReader interface { type CachedUsersRepository struct { cache *JSONCache - next UsersReader + next UsersRepository ttl time.Duration } type CachedHotelsRepository struct { cache *JSONCache - next HotelsReader + next HotelsRepository ttl time.Duration } type CachedGuestsRepository struct { - cache *JSONCache - next storage.GuestsRepository - guestTTL time.Duration + cache *JSONCache + next storage.GuestsRepository + guestTTL time.Duration guestStaysTTL time.Duration } @@ -47,11 +50,11 @@ type CachedGuestBookingsRepository struct { ttl time.Duration } -func NewCachedUsersRepository(cache *JSONCache, next UsersReader, ttl time.Duration) *CachedUsersRepository { +func NewCachedUsersRepository(cache *JSONCache, next UsersRepository, ttl time.Duration) *CachedUsersRepository { return &CachedUsersRepository{cache: cache, next: next, ttl: ttl} } -func NewCachedHotelsRepository(cache *JSONCache, next HotelsReader, ttl time.Duration) *CachedHotelsRepository { +func NewCachedHotelsRepository(cache *JSONCache, next HotelsRepository, ttl time.Duration) *CachedHotelsRepository { return &CachedHotelsRepository{cache: cache, next: next, ttl: ttl} } @@ -70,7 +73,9 @@ func (r *CachedUsersRepository) FindUser(ctx context.Context, id string) (*model key := userKey(id) var cached models.User - if hit, err := r.cache.GetJSON(ctx, key, &cached); err == nil && hit { + if hit, err := r.cache.GetJSON(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { return &cached, nil } @@ -79,28 +84,18 @@ func (r *CachedUsersRepository) FindUser(ctx context.Context, id string) (*model return nil, err } - _ = r.cache.SetJSON(ctx, key, user, r.ttl) + if err := r.cache.SetJSON(ctx, key, user, r.ttl); err != nil { + r.cache.WarnWriteError(key, err) + } return user, nil } func (r *CachedUsersRepository) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { - repo, ok := r.next.(interface { - InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) - }) - if !ok { - return nil, nil - } - return repo.InsertUser(ctx, user) + return r.next.InsertUser(ctx, user) } func (r *CachedUsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { - repo, ok := r.next.(interface { - UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) - }) - if !ok { - return nil, nil - } - return repo.UpdateUser(ctx, id, update) + return r.next.UpdateUser(ctx, id, update) } func (r *CachedHotelsRepository) FindByID(ctx context.Context, id string) (*models.Hotel, error) { @@ -110,7 +105,9 @@ func (r *CachedHotelsRepository) FindByID(ctx context.Context, id string) (*mode key := hotelKey(id) var cached models.Hotel - if hit, err := r.cache.GetJSON(ctx, key, &cached); err == nil && hit { + if hit, err := r.cache.GetJSON(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { return &cached, nil } @@ -119,18 +116,14 @@ func (r *CachedHotelsRepository) FindByID(ctx context.Context, id string) (*mode return nil, err } - _ = r.cache.SetJSON(ctx, key, hotel, r.ttl) + if err := r.cache.SetJSON(ctx, key, hotel, r.ttl); err != nil { + r.cache.WarnWriteError(key, err) + } return hotel, nil } func (r *CachedHotelsRepository) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - repo, ok := r.next.(interface { - InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) - }) - if !ok { - return nil, nil - } - return repo.InsertHotel(ctx, hotel) + return r.next.InsertHotel(ctx, hotel) } func (r *CachedGuestsRepository) InsertGuest(ctx context.Context, guest *models.CreateGuest) (*models.Guest, error) { @@ -144,7 +137,9 @@ func (r *CachedGuestsRepository) FindGuest(ctx context.Context, id string) (*mod key := guestKey(id) var cached models.Guest - if hit, err := r.cache.GetJSON(ctx, key, &cached); err == nil && hit { + if hit, err := r.cache.GetJSON(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { return &cached, nil } @@ -153,7 +148,9 @@ func (r *CachedGuestsRepository) FindGuest(ctx context.Context, id string) (*mod return nil, err } - _ = r.cache.SetJSON(ctx, key, guest, r.guestTTL) + if err := r.cache.SetJSON(ctx, key, guest, r.guestTTL); err != nil { + r.cache.WarnWriteError(key, err) + } return guest, nil } @@ -172,7 +169,9 @@ func (r *CachedGuestsRepository) FindGuestWithStayHistory(ctx context.Context, i key := guestStaysKey(id) var cached models.GuestWithStays - if hit, err := r.cache.GetJSON(ctx, key, &cached); err == nil && hit { + if hit, err := r.cache.GetJSON(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { return &cached, nil } @@ -181,7 +180,9 @@ func (r *CachedGuestsRepository) FindGuestWithStayHistory(ctx context.Context, i return nil, err } - _ = r.cache.SetJSON(ctx, key, guest, r.guestStaysTTL) + if err := r.cache.SetJSON(ctx, key, guest, r.guestStaysTTL); err != nil { + r.cache.WarnWriteError(key, err) + } return guest, nil } @@ -192,7 +193,9 @@ func (r *CachedGuestBookingsRepository) FindGroupSizeOptions(ctx context.Context key := guestBookingGroupSizesKey(hotelID) var cached []int - if hit, err := r.cache.GetJSON(ctx, key, &cached); err == nil && hit { + if hit, err := r.cache.GetJSON(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { return cached, nil } @@ -201,7 +204,9 @@ func (r *CachedGuestBookingsRepository) FindGroupSizeOptions(ctx context.Context return nil, err } - _ = r.cache.SetJSON(ctx, key, sizes, r.ttl) + if err := r.cache.SetJSON(ctx, key, sizes, r.ttl); err != nil { + r.cache.WarnWriteError(key, err) + } return sizes, nil } diff --git a/backend/internal/cache/readers_test.go b/backend/internal/cache/readers_test.go index ee81d1d9..eeb8b334 100644 --- a/backend/internal/cache/readers_test.go +++ b/backend/internal/cache/readers_test.go @@ -1,8 +1,10 @@ package cache import ( + "bytes" "context" "errors" + "log/slog" "testing" "time" @@ -233,3 +235,49 @@ func TestCachedGuestBookingsRepository_FindGroupSizeOptionsUsesHotelScopedKey(t assert.Equal(t, []int{1, 2, 4}, sizes) assert.Equal(t, "selfserve:v1:guest_booking_group_sizes:hotel-1", store.lastSetKey) } + +func TestCachedUsersRepository_FindUserLogsWarnOnCacheReadError(t *testing.T) { + t.Parallel() + + var logBuffer bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuffer, nil)) + + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil + }, + } + store := &fakeKVStore{getErr: errors.New("redis unavailable")} + + cached := NewCachedUsersRepository(NewJSONCache(store, logger), repo, 5*time.Minute) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", user.FirstName) + assert.Contains(t, logBuffer.String(), "redis cache read failed") + assert.Contains(t, logBuffer.String(), "key=selfserve:v1:user:user-1") + assert.Contains(t, logBuffer.String(), "redis unavailable") +} + +func TestCachedUsersRepository_FindUserLogsWarnOnCacheWriteError(t *testing.T) { + t.Parallel() + + var logBuffer bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuffer, nil)) + + repo := &stubUsersRepo{ + findUserFn: func(_ context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, FirstName: "Ada"}}, nil + }, + } + store := &fakeKVStore{setErr: errors.New("redis write unavailable")} + + cached := NewCachedUsersRepository(NewJSONCache(store, logger), repo, 5*time.Minute) + user, err := cached.FindUser(context.Background(), "user-1") + + require.NoError(t, err) + assert.Equal(t, "Ada", user.FirstName) + assert.Contains(t, logBuffer.String(), "redis cache write failed") + assert.Contains(t, logBuffer.String(), "key=selfserve:v1:user:user-1") + assert.Contains(t, logBuffer.String(), "redis write unavailable") +} diff --git a/backend/internal/service/cached_repos.go b/backend/internal/service/cached_repos.go index e547152d..806619e5 100644 --- a/backend/internal/service/cached_repos.go +++ b/backend/internal/service/cached_repos.go @@ -39,4 +39,3 @@ func buildGuestBookingsRepository( } return cache.NewCachedGuestBookingsRepository(jsonCache, repo, 5*time.Minute) } - diff --git a/backend/internal/service/cached_repos_test.go b/backend/internal/service/cached_repos_test.go deleted file mode 100644 index e12ba806..00000000 --- a/backend/internal/service/cached_repos_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/generate/selfserve/internal/cache" - "github.com/generate/selfserve/internal/repository" -) - -type noopStore struct{} - -func (noopStore) Get(_ context.Context, _ string) (string, error) { - return "", cache.ErrCacheMiss -} - -func (noopStore) Set(_ context.Context, _ string, _ string, _ time.Duration) error { - return nil -} - -func TestBuildUsersRepositoryUsesCacheWhenAvailable(t *testing.T) { - t.Parallel() - - repo := &repository.UsersRepository{} - cachedRepo := buildUsersRepository(cache.NewJSONCache(noopStore{}), repo) - - if _, ok := cachedRepo.(*cache.CachedUsersRepository); !ok { - t.Fatalf("expected cached users repository, got %T", cachedRepo) - } -} - -func TestBuildUsersRepositoryFallsBackWithoutCache(t *testing.T) { - t.Parallel() - - repo := &repository.UsersRepository{} - plainRepo := buildUsersRepository(nil, repo) - - if plainRepo != repo { - t.Fatalf("expected original users repository, got %T", plainRepo) - } -} - -func TestBuildGuestsRepositoryUsesCacheWhenAvailable(t *testing.T) { - t.Parallel() - - repo := &repository.GuestsRepository{} - cachedRepo := buildGuestsRepository(cache.NewJSONCache(noopStore{}), repo) - - if _, ok := cachedRepo.(*cache.CachedGuestsRepository); !ok { - t.Fatalf("expected cached guests repository, got %T", cachedRepo) - } -} - -func TestBuildHotelsRepositoryUsesCacheWhenAvailable(t *testing.T) { - t.Parallel() - - repo := &repository.HotelsRepository{} - cachedRepo := buildHotelsRepository(cache.NewJSONCache(noopStore{}), repo) - - if _, ok := cachedRepo.(*cache.CachedHotelsRepository); !ok { - t.Fatalf("expected cached hotels repository, got %T", cachedRepo) - } -} - -func TestBuildGuestBookingsRepositoryUsesCacheWhenAvailable(t *testing.T) { - t.Parallel() - - repo := &repository.GuestBookingsRepository{} - cachedRepo := buildGuestBookingsRepository(cache.NewJSONCache(noopStore{}), repo) - - if _, ok := cachedRepo.(*cache.CachedGuestBookingsRepository); !ok { - t.Fatalf("expected cached guest bookings repository, got %T", cachedRepo) - } -} - diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index b4ffba0b..39b77d16 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -42,6 +42,19 @@ type App struct { RedisClient *goredis.Client } +func (a *App) Close() error { + if a == nil { + return nil + } + + 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() @@ -57,10 +70,7 @@ func InitApp(cfg *config.Config) (*App, error) { 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) @@ -71,10 +81,7 @@ func InitApp(cfg *config.Config) (*App, error) { setupClerk(cfg) if err = setupRoutes(app, repo, genkitInstance, cfg, s3Store, openSearchRepos, jsonCache); err != nil { //nolint:wsl - 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)) } return &App{ From 32ef2582c3388f31326c1ad6afeccedcc6c87552 Mon Sep 17 00:00:00 2001 From: Zaydaan Jahangir Date: Sat, 4 Apr 2026 18:45:57 -0400 Subject: [PATCH 3/3] style: Fix styling, lean out tests --- backend/config/config.go | 1 - backend/internal/cache/readers.go | 29 +++++++++++++++++++ backend/internal/cache/readers_test.go | 24 +++++++++++++++ backend/internal/service/cached_repos.go | 2 +- backend/internal/storage/redis/client_test.go | 29 ------------------- 5 files changed, 54 insertions(+), 31 deletions(-) diff --git a/backend/config/config.go b/backend/config/config.go index 50ca93f8..0e2240e1 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -6,7 +6,6 @@ type Config struct { S3 `env:",prefix=AWS_S3_"` LLM `env:",prefix=LLM_"` Clerk `env:",prefix=CLERK_"` - S3 `env:",prefix=AWS_S3_"` Redis `env:",prefix=REDIS_"` OpenSearch `env:",prefix=OPENSEARCH_"` } diff --git a/backend/internal/cache/readers.go b/backend/internal/cache/readers.go index b4463bb4..21799cd9 100644 --- a/backend/internal/cache/readers.go +++ b/backend/internal/cache/readers.go @@ -14,6 +14,11 @@ type UsersRepository interface { FindUser(ctx context.Context, id string) (*models.User, error) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) + UpdateProfilePicture(ctx context.Context, userID string, key string) error + DeleteProfilePicture(ctx context.Context, userID string) error + GetKey(ctx context.Context, userID string) (string, error) + BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error + SearchUsersByHotel(ctx context.Context, hotelID, cursor, query string, limit int) ([]*models.User, string, error) } type HotelsRepository interface { @@ -98,6 +103,30 @@ func (r *CachedUsersRepository) UpdateUser(ctx context.Context, id string, updat return r.next.UpdateUser(ctx, id, update) } +func (r *CachedUsersRepository) UpdateProfilePicture(ctx context.Context, userID string, key string) error { + return r.next.UpdateProfilePicture(ctx, userID, key) +} + +func (r *CachedUsersRepository) DeleteProfilePicture(ctx context.Context, userID string) error { + return r.next.DeleteProfilePicture(ctx, userID) +} + +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) 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 *CachedHotelsRepository) FindByID(ctx context.Context, id string) (*models.Hotel, error) { if r.cache == nil { return r.next.FindByID(ctx, id) diff --git a/backend/internal/cache/readers_test.go b/backend/internal/cache/readers_test.go index eeb8b334..09f8f94e 100644 --- a/backend/internal/cache/readers_test.go +++ b/backend/internal/cache/readers_test.go @@ -33,6 +33,30 @@ func (s *stubUsersRepo) UpdateUser(_ context.Context, id string, update *models. return &models.User{CreateUser: models.CreateUser{ID: id, PhoneNumber: update.PhoneNumber}}, nil } +func (s *stubUsersRepo) UpdateProfilePicture(_ context.Context, _ string, _ string) error { + return nil +} + +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) SearchUsersByHotel( + _ context.Context, + _, _, _ string, + _ int, +) ([]*models.User, string, error) { + return nil, "", nil +} + type stubHotelsRepo struct { findByIDCalls int findByIDFn func(ctx context.Context, id string) (*models.Hotel, error) diff --git a/backend/internal/service/cached_repos.go b/backend/internal/service/cached_repos.go index 806619e5..8da88021 100644 --- a/backend/internal/service/cached_repos.go +++ b/backend/internal/service/cached_repos.go @@ -9,7 +9,7 @@ import ( storage "github.com/generate/selfserve/internal/service/storage/postgres" ) -func buildUsersRepository(jsonCache *cache.JSONCache, repo *repository.UsersRepository) handler.UsersRepository { +func buildUsersRepository(jsonCache *cache.JSONCache, repo *repository.UsersRepository) storage.UsersRepository { if jsonCache == nil { return repo } diff --git a/backend/internal/storage/redis/client_test.go b/backend/internal/storage/redis/client_test.go index 48680c5e..a2de2cdb 100644 --- a/backend/internal/storage/redis/client_test.go +++ b/backend/internal/storage/redis/client_test.go @@ -1,7 +1,6 @@ package redis import ( - "context" "testing" "github.com/generate/selfserve/config" @@ -22,31 +21,3 @@ func TestInitRedisUsesConfigValues(t *testing.T) { assert.Equal(t, "secret", options.Password) assert.Equal(t, 0, options.DB) } - -func TestRedisConnection(t *testing.T) { - client, err := InitRedis(config.Redis{Addr: "localhost:6379"}) - if err != nil { - t.Skipf("Skipping test: Redis not available: %v", err) - } - defer func() { - _ = Close(client) - }() - - ctx := context.Background() - - // Test Set - err = client.Set(ctx, "test_key", "test_value", 0).Err() - if err != nil { - t.Fatalf("Failed to set value: %v", err) - } - 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) - } -}