diff --git a/cmd/main.go b/cmd/main.go index aa34310..98bb35e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,7 +1,7 @@ // Package main provides the entry point for the Sulawesi Tengah COVID-19 Data API // // @title Sulawesi Tengah COVID-19 Data API -// @version 2.7.0 +// @version 2.8.0 // @description A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. Features enhanced ODP/PDP grouping, hybrid pagination, and rate limiting protection. Rate limiting: 100 requests per minute per IP address by default, with appropriate HTTP headers for client guidance. // @termsOfService http://swagger.io/terms/ // @@ -34,8 +34,8 @@ import ( "fmt" "log" "net/http" - "os" + "time" "github.com/banua-coder/pico-api-go/docs" "github.com/banua-coder/pico-api-go/internal/config" @@ -43,6 +43,7 @@ import ( "github.com/banua-coder/pico-api-go/internal/middleware" "github.com/banua-coder/pico-api-go/internal/repository" "github.com/banua-coder/pico-api-go/internal/service" + "github.com/banua-coder/pico-api-go/pkg/cache" "github.com/banua-coder/pico-api-go/pkg/database" ) @@ -65,7 +66,36 @@ func main() { provinceRepo := repository.NewProvinceRepository(db) provinceCaseRepo := repository.NewProvinceCaseRepository(db) - covidService := service.NewCovidService(nationalCaseRepo, provinceRepo, provinceCaseRepo) + // Initialize cache — use Redis-backed dual-layer if REDIS_ADDR is set, otherwise in-memory only + var c *cache.Cache + var cacheInvalidator service.CacheInvalidator + + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr != "" { + rac, err := cache.NewRedisAwareCache(time.Hour, cache.RedisOptions{ + Addr: redisAddr, + Password: os.Getenv("REDIS_PASSWORD"), + DB: 0, + }) + if err != nil { + log.Printf("Redis unavailable (%v), falling back to in-memory cache only", err) + c = cache.New(time.Hour) + cacheInvalidator = c + } else { + log.Printf("Redis connected: %s (dual-layer cache active)", redisAddr) + c = rac.Unwrap() + cacheInvalidator = rac + } + } else { + c = cache.New(time.Hour) + cacheInvalidator = c + } + c.StartCleanup(5 * time.Minute) + + covidService := service.NewCachedCovidService( + service.NewCovidService(nationalCaseRepo, provinceRepo, provinceCaseRepo), + c, + ) // New repositories and services for migrated Lumen endpoints regencyRepo := repository.NewRegencyRepository(db) @@ -73,7 +103,10 @@ func main() { hospitalRepo := repository.NewHospitalRepository(db) taskForceRepo := repository.NewTaskForceRepository(db) - regencyService := service.NewRegencyService(regencyRepo, regencyCaseRepo) + regencyService := service.NewCachedRegencyService( + service.NewRegencyService(regencyRepo, regencyCaseRepo), + c, + ) hospitalService := service.NewHospitalService(hospitalRepo) taskForceService := service.NewTaskForceService(taskForceRepo) @@ -98,6 +131,7 @@ func main() { svc := handler.Services{ CovidService: covidService, RegencyService: regencyService, + CacheInvalidator: cacheInvalidator, HospitalService: hospitalService, TaskForceService: taskForceService, VaccinationService: vaccinationService, diff --git a/go.mod b/go.mod index cc0a6f7..62a47cd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/mux v1.8.1 github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.18.0 github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 @@ -17,7 +18,9 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect github.com/go-openapi/jsonreference v0.21.1 // indirect github.com/go-openapi/spec v0.21.0 // indirect @@ -39,6 +42,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/swaggo/files v1.0.1 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/go.sum b/go.sum index 83d2836..f38eaad 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,16 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= @@ -47,6 +55,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -55,6 +65,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -68,6 +80,10 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go new file mode 100644 index 0000000..60acdf1 --- /dev/null +++ b/internal/handler/admin_handler.go @@ -0,0 +1,42 @@ +package handler + +import ( + "net/http" + "os" + + "github.com/banua-coder/pico-api-go/internal/service" +) + +// AdminHandler handles admin endpoints. +type AdminHandler struct { + invalidator service.CacheInvalidator +} + +// NewAdminHandler creates a new AdminHandler. +func NewAdminHandler(invalidator service.CacheInvalidator) *AdminHandler { + return &AdminHandler{invalidator: invalidator} +} + +// ClearCache godoc +// +// @Summary Clear all in-memory cache +// @Description Clears all cached data. Requires X-Admin-Key header matching ADMIN_KEY env var. +// @Tags admin +// @Produce json +// @Param X-Admin-Key header string true "Admin key" +// @Success 200 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /admin/cache/clear [post] +func (h *AdminHandler) ClearCache(w http.ResponseWriter, r *http.Request) { + adminKey := os.Getenv("ADMIN_KEY") + if adminKey == "" || r.Header.Get("X-Admin-Key") != adminKey { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) //nolint:errcheck + return + } + h.invalidator.Clear() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message":"cache cleared"}`)) //nolint:errcheck +} diff --git a/internal/handler/admin_handler_test.go b/internal/handler/admin_handler_test.go new file mode 100644 index 0000000..15caa3b --- /dev/null +++ b/internal/handler/admin_handler_test.go @@ -0,0 +1,92 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockCacheInvalidator struct { + mock.Mock +} + +func (m *MockCacheInvalidator) Clear() { + m.Called() +} + +func TestNewAdminHandler(t *testing.T) { + invalidator := new(MockCacheInvalidator) + h := NewAdminHandler(invalidator) + assert.NotNil(t, h) +} + +func TestAdminHandler_ClearCache_Success(t *testing.T) { + t.Setenv("ADMIN_KEY", "test-secret-key") + + invalidator := new(MockCacheInvalidator) + invalidator.On("Clear").Once() + + h := NewAdminHandler(invalidator) + + req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", nil) + req.Header.Set("X-Admin-Key", "test-secret-key") + w := httptest.NewRecorder() + + h.ClearCache(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "cache cleared") + invalidator.AssertExpectations(t) +} + +func TestAdminHandler_ClearCache_WrongKey(t *testing.T) { + t.Setenv("ADMIN_KEY", "test-secret-key") + + invalidator := new(MockCacheInvalidator) + h := NewAdminHandler(invalidator) + + req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", nil) + req.Header.Set("X-Admin-Key", "wrong-key") + w := httptest.NewRecorder() + + h.ClearCache(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "unauthorized") + invalidator.AssertNotCalled(t, "Clear") +} + +func TestAdminHandler_ClearCache_NoKey(t *testing.T) { + t.Setenv("ADMIN_KEY", "test-secret-key") + + invalidator := new(MockCacheInvalidator) + h := NewAdminHandler(invalidator) + + req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", nil) + w := httptest.NewRecorder() + + h.ClearCache(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + invalidator.AssertNotCalled(t, "Clear") +} + +func TestAdminHandler_ClearCache_EmptyAdminKeyEnv(t *testing.T) { + t.Setenv("ADMIN_KEY", "") + + invalidator := new(MockCacheInvalidator) + h := NewAdminHandler(invalidator) + + req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", strings.NewReader("")) + req.Header.Set("X-Admin-Key", "any-key") + w := httptest.NewRecorder() + + h.ClearCache(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + invalidator.AssertNotCalled(t, "Clear") +} diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index d593b12..b20b24f 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -350,7 +350,7 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { health := map[string]interface{}{ "status": "healthy", "service": "COVID-19 API", - "version": "2.7.0", + "version": "2.8.0", "timestamp": time.Now().UTC().Format(time.RFC3339), } @@ -459,7 +459,7 @@ func (h *CovidHandler) GetAPIIndex(w http.ResponseWriter, r *http.Request) { endpoints := map[string]interface{}{ "api": map[string]interface{}{ "title": "Sulawesi Tengah COVID-19 Data API", - "version": "2.7.0", + "version": "2.8.0", "description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi)", }, "documentation": map[string]interface{}{ diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index bf82a9b..93427de 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -645,7 +645,7 @@ func TestCovidHandler_GetAPIIndex(t *testing.T) { apiInfo, ok := data["api"].(map[string]interface{}) assert.True(t, ok) assert.Equal(t, "Sulawesi Tengah COVID-19 Data API", apiInfo["title"]) - assert.Equal(t, "2.7.0", apiInfo["version"]) + assert.Equal(t, "2.8.0", apiInfo["version"]) // Verify endpoints structure endpoints, ok := data["endpoints"].(map[string]interface{}) @@ -676,7 +676,7 @@ func TestCovidHandler_HealthCheck(t *testing.T) { assert.True(t, ok) assert.Equal(t, "degraded", data["status"]) assert.Equal(t, "COVID-19 API", data["service"]) - assert.Equal(t, "2.7.0", data["version"]) + assert.Equal(t, "2.8.0", data["version"]) assert.Contains(t, data, "database") dbData, ok := data["database"].(map[string]interface{}) diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 4b665f3..e79fa8e 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -12,11 +12,12 @@ import ( // Services holds all service dependencies for route setup type Services struct { CovidService service.CovidService - RegencyService *service.RegencyService + RegencyService service.RegencyServiceInterface HospitalService *service.HospitalService TaskForceService *service.TaskForceService VaccinationService *service.VaccinationService ProvinceStatsService service.ProvinceStatsServiceInterface + CacheInvalidator service.CacheInvalidator } func SetupRoutes(svc Services, db *database.DB, enableSwagger bool) *mux.Router { @@ -78,6 +79,12 @@ func SetupRoutes(svc Services, db *database.DB, enableSwagger bool) *mux.Router api.HandleFunc("/stats/test-types", statsHandler.GetTestTypes).Methods("GET", "OPTIONS") } + // Admin endpoints + if svc.CacheInvalidator != nil { + adminHandler := NewAdminHandler(svc.CacheInvalidator) + router.HandleFunc("/admin/cache/clear", adminHandler.ClearCache).Methods("POST", "OPTIONS") + } + // Conditionally add Swagger documentation based on environment if enableSwagger { router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) diff --git a/internal/service/cached_covid_service.go b/internal/service/cached_covid_service.go new file mode 100644 index 0000000..9a930f9 --- /dev/null +++ b/internal/service/cached_covid_service.go @@ -0,0 +1,438 @@ +package service + +import ( + "fmt" + "time" + + "github.com/banua-coder/pico-api-go/internal/models" + "github.com/banua-coder/pico-api-go/pkg/cache" + "github.com/banua-coder/pico-api-go/pkg/utils" +) + +const ( + ttlLatest = 15 * time.Minute + ttlHistorical = 24 * time.Hour + ttlDefault = time.Hour +) + +// CacheInvalidator is the interface for cache invalidation. +type CacheInvalidator interface { + Clear() +} + +// cachedCovidService wraps a CovidService with in-memory caching. +type cachedCovidService struct { + svc CovidService + cache *cache.Cache +} + +// NewCachedCovidService returns a CovidService backed by an in-memory cache. +func NewCachedCovidService(svc CovidService, c *cache.Cache) CovidService { + return &cachedCovidService{svc: svc, cache: c} +} + +// -- helper ---------------------------------------------------------- + +func (s *cachedCovidService) getOrSet(key string, ttl time.Duration, fn func() (interface{}, error)) (interface{}, error) { + if v, ok := s.cache.Get(key); ok { + return v, nil + } + v, err := fn() + if err != nil { + return nil, err + } + s.cache.Set(key, v, ttl) + return v, nil +} + +// -- national cases -------------------------------------------------- + +func (s *cachedCovidService) GetNationalCases() ([]models.NationalCase, error) { + v, err := s.getOrSet("national:all", ttlDefault, func() (interface{}, error) { + return s.svc.GetNationalCases() + }) + if err != nil { + return nil, err + } + return v.([]models.NationalCase), nil +} + +func (s *cachedCovidService) GetNationalCasesSorted(sortParams utils.SortParams) ([]models.NationalCase, error) { + key := fmt.Sprintf("national:all:sort:%s:%s", sortParams.Field, sortParams.Order) + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + return s.svc.GetNationalCasesSorted(sortParams) + }) + if err != nil { + return nil, err + } + return v.([]models.NationalCase), nil +} + +func (s *cachedCovidService) GetNationalCasesPaginated(limit, offset int) ([]models.NationalCase, int, error) { + key := fmt.Sprintf("national:all:page:%d:%d", limit, offset) + type result struct { + cases []models.NationalCase + total int + } + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + cases, total, err := s.svc.GetNationalCasesPaginated(limit, offset) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + key := fmt.Sprintf("national:all:page:%d:%d:sort:%s:%s", limit, offset, sortParams.Field, sortParams.Order) + type result struct { + cases []models.NationalCase + total int + } + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + cases, total, err := s.svc.GetNationalCasesPaginatedSorted(limit, offset, sortParams) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetNationalCasesByDateRange(startDate, endDate string) ([]models.NationalCase, error) { + key := fmt.Sprintf("national:date:%s:%s", startDate, endDate) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetNationalCasesByDateRange(startDate, endDate) + }) + if err != nil { + return nil, err + } + return v.([]models.NationalCase), nil +} + +func (s *cachedCovidService) GetNationalCasesByDateRangeSorted(startDate, endDate string, sortParams utils.SortParams) ([]models.NationalCase, error) { + key := fmt.Sprintf("national:date:%s:%s:sort:%s:%s", startDate, endDate, sortParams.Field, sortParams.Order) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams) + }) + if err != nil { + return nil, err + } + return v.([]models.NationalCase), nil +} + +func (s *cachedCovidService) GetNationalCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.NationalCase, int, error) { + key := fmt.Sprintf("national:date:%s:%s:page:%d:%d", startDate, endDate, limit, offset) + type result struct { + cases []models.NationalCase + total int + } + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + cases, total, err := s.svc.GetNationalCasesByDateRangePaginated(startDate, endDate, limit, offset) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + key := fmt.Sprintf("national:date:%s:%s:page:%d:%d:sort:%s:%s", startDate, endDate, limit, offset, sortParams.Field, sortParams.Order) + type result struct { + cases []models.NationalCase + total int + } + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + cases, total, err := s.svc.GetNationalCasesByDateRangePaginatedSorted(startDate, endDate, limit, offset, sortParams) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetLatestNationalCase() (*models.NationalCase, error) { + v, err := s.getOrSet("national:latest", ttlLatest, func() (interface{}, error) { + return s.svc.GetLatestNationalCase() + }) + if err != nil { + return nil, err + } + return v.(*models.NationalCase), nil +} + +func (s *cachedCovidService) GetNationalCaseByDay(day int64) (*models.NationalCase, error) { + key := fmt.Sprintf("national:day:%d", day) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetNationalCaseByDay(day) + }) + if err != nil { + return nil, err + } + return v.(*models.NationalCase), nil +} + +// -- provinces ------------------------------------------------------- + +func (s *cachedCovidService) GetProvinces() ([]models.Province, error) { + v, err := s.getOrSet("province:all", ttlDefault, func() (interface{}, error) { + return s.svc.GetProvinces() + }) + if err != nil { + return nil, err + } + return v.([]models.Province), nil +} + +func (s *cachedCovidService) GetProvinceByID(id string) (*models.Province, error) { + key := fmt.Sprintf("province:%s", id) + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + return s.svc.GetProvinceByID(id) + }) + if err != nil { + return nil, err + } + return v.(*models.Province), nil +} + +func (s *cachedCovidService) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) { + v, err := s.getOrSet("province:all:with_latest", ttlLatest, func() (interface{}, error) { + return s.svc.GetProvincesWithLatestCase() + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceWithLatestCase), nil +} + +func (s *cachedCovidService) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:%s:cases:all", provinceID) + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + return s.svc.GetProvinceCases(provinceID) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetProvinceCasesSorted(provinceID string, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:%s:cases:all:sort:%s:%s", provinceID, sortParams.Field, sortParams.Order) + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + return s.svc.GetProvinceCasesSorted(provinceID, sortParams) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:%s:cases:page:%d:%d", provinceID, limit, offset) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + cases, total, err := s.svc.GetProvinceCasesPaginated(provinceID, limit, offset) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetProvinceCasesPaginatedSorted(provinceID string, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:%s:cases:page:%d:%d:sort:%s:%s", provinceID, limit, offset, sortParams.Field, sortParams.Order) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + cases, total, err := s.svc.GetProvinceCasesPaginatedSorted(provinceID, limit, offset, sortParams) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetProvinceCasesByDateRange(provinceID, startDate, endDate string) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:%s:cases:date:%s:%s", provinceID, startDate, endDate) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetProvinceCasesByDateRange(provinceID, startDate, endDate) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetProvinceCasesByDateRangeSorted(provinceID, startDate, endDate string, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:%s:cases:date:%s:%s:sort:%s:%s", provinceID, startDate, endDate, sortParams.Field, sortParams.Order) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetProvinceCasesByDateRangeSorted(provinceID, startDate, endDate, sortParams) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:%s:cases:date:%s:%s:page:%d:%d", provinceID, startDate, endDate, limit, offset) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + cases, total, err := s.svc.GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate, limit, offset) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetProvinceCasesByDateRangePaginatedSorted(provinceID, startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:%s:cases:date:%s:%s:page:%d:%d:sort:%s:%s", provinceID, startDate, endDate, limit, offset, sortParams.Field, sortParams.Order) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + cases, total, err := s.svc.GetProvinceCasesByDateRangePaginatedSorted(provinceID, startDate, endDate, limit, offset, sortParams) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +// -- all province cases ---------------------------------------------- + +func (s *cachedCovidService) GetAllProvinceCases() ([]models.ProvinceCaseWithDate, error) { + v, err := s.getOrSet("province:cases:all", ttlDefault, func() (interface{}, error) { + return s.svc.GetAllProvinceCases() + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetAllProvinceCasesSorted(sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:cases:all:sort:%s:%s", sortParams.Field, sortParams.Order) + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + return s.svc.GetAllProvinceCasesSorted(sortParams) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:cases:all:page:%d:%d", limit, offset) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + cases, total, err := s.svc.GetAllProvinceCasesPaginated(limit, offset) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetAllProvinceCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:cases:all:page:%d:%d:sort:%s:%s", limit, offset, sortParams.Field, sortParams.Order) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlDefault, func() (interface{}, error) { + cases, total, err := s.svc.GetAllProvinceCasesPaginatedSorted(limit, offset, sortParams) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetAllProvinceCasesByDateRange(startDate, endDate string) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:cases:date:%s:%s", startDate, endDate) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetAllProvinceCasesByDateRange(startDate, endDate) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetAllProvinceCasesByDateRangeSorted(startDate, endDate string, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + key := fmt.Sprintf("province:cases:date:%s:%s:sort:%s:%s", startDate, endDate, sortParams.Field, sortParams.Order) + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + return s.svc.GetAllProvinceCasesByDateRangeSorted(startDate, endDate, sortParams) + }) + if err != nil { + return nil, err + } + return v.([]models.ProvinceCaseWithDate), nil +} + +func (s *cachedCovidService) GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:cases:date:%s:%s:page:%d:%d", startDate, endDate, limit, offset) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + cases, total, err := s.svc.GetAllProvinceCasesByDateRangePaginated(startDate, endDate, limit, offset) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} + +func (s *cachedCovidService) GetAllProvinceCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + key := fmt.Sprintf("province:cases:date:%s:%s:page:%d:%d:sort:%s:%s", startDate, endDate, limit, offset, sortParams.Field, sortParams.Order) + type result struct { + cases []models.ProvinceCaseWithDate + total int + } + v, err := s.getOrSet(key, ttlHistorical, func() (interface{}, error) { + cases, total, err := s.svc.GetAllProvinceCasesByDateRangePaginatedSorted(startDate, endDate, limit, offset, sortParams) + return result{cases, total}, err + }) + if err != nil { + return nil, 0, err + } + r := v.(result) + return r.cases, r.total, nil +} diff --git a/internal/service/cached_covid_service_test.go b/internal/service/cached_covid_service_test.go new file mode 100644 index 0000000..dee0f3f --- /dev/null +++ b/internal/service/cached_covid_service_test.go @@ -0,0 +1,967 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/banua-coder/pico-api-go/internal/models" + "github.com/banua-coder/pico-api-go/pkg/cache" + "github.com/banua-coder/pico-api-go/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockCovidService mocks CovidService interface +type MockCovidService struct { + mock.Mock +} + +func (m *MockCovidService) GetNationalCases() ([]models.NationalCase, error) { + args := m.Called() + return args.Get(0).([]models.NationalCase), args.Error(1) +} +func (m *MockCovidService) GetNationalCasesSorted(s utils.SortParams) ([]models.NationalCase, error) { + args := m.Called(s) + return args.Get(0).([]models.NationalCase), args.Error(1) +} +func (m *MockCovidService) GetNationalCasesPaginated(limit, offset int) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetNationalCasesPaginatedSorted(limit, offset int, s utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, s) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetNationalCasesByDateRange(start, end string) ([]models.NationalCase, error) { + args := m.Called(start, end) + return args.Get(0).([]models.NationalCase), args.Error(1) +} +func (m *MockCovidService) GetNationalCasesByDateRangeSorted(start, end string, s utils.SortParams) ([]models.NationalCase, error) { + args := m.Called(start, end, s) + return args.Get(0).([]models.NationalCase), args.Error(1) +} +func (m *MockCovidService) GetNationalCasesByDateRangePaginated(start, end string, limit, offset int) ([]models.NationalCase, int, error) { + args := m.Called(start, end, limit, offset) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetNationalCasesByDateRangePaginatedSorted(start, end string, limit, offset int, s utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(start, end, limit, offset, s) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetLatestNationalCase() (*models.NationalCase, error) { + args := m.Called() + res := args.Get(0) + if res == nil { + return nil, args.Error(1) + } + return res.(*models.NationalCase), args.Error(1) +} +func (m *MockCovidService) GetNationalCaseByDay(day int64) (*models.NationalCase, error) { + args := m.Called(day) + res := args.Get(0) + if res == nil { + return nil, args.Error(1) + } + return res.(*models.NationalCase), args.Error(1) +} +func (m *MockCovidService) GetProvinces() ([]models.Province, error) { + args := m.Called() + return args.Get(0).([]models.Province), args.Error(1) +} +func (m *MockCovidService) GetProvinceByID(id string) (*models.Province, error) { + args := m.Called(id) + res := args.Get(0) + if res == nil { + return nil, args.Error(1) + } + return res.(*models.Province), args.Error(1) +} +func (m *MockCovidService) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) { + args := m.Called() + return args.Get(0).([]models.ProvinceWithLatestCase), args.Error(1) +} +func (m *MockCovidService) GetProvinceCases(pid string) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(pid) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetProvinceCasesSorted(pid string, s utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(pid, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetProvinceCasesPaginated(pid string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(pid, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetProvinceCasesPaginatedSorted(pid string, limit, offset int, s utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(pid, limit, offset, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetProvinceCasesByDateRange(pid, start, end string) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(pid, start, end) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetProvinceCasesByDateRangeSorted(pid, start, end string, s utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(pid, start, end, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetProvinceCasesByDateRangePaginated(pid, start, end string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(pid, start, end, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetProvinceCasesByDateRangePaginatedSorted(pid, start, end string, limit, offset int, s utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(pid, start, end, limit, offset, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetAllProvinceCases() ([]models.ProvinceCaseWithDate, error) { + args := m.Called() + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetAllProvinceCasesSorted(s utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetAllProvinceCasesPaginatedSorted(limit, offset int, s utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetAllProvinceCasesByDateRange(start, end string) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(start, end) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetAllProvinceCasesByDateRangeSorted(start, end string, s utils.SortParams) ([]models.ProvinceCaseWithDate, error) { + args := m.Called(start, end, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) +} +func (m *MockCovidService) GetAllProvinceCasesByDateRangePaginated(start, end string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(start, end, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} +func (m *MockCovidService) GetAllProvinceCasesByDateRangePaginatedSorted(start, end string, limit, offset int, s utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(start, end, limit, offset, s) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func newTestCache() *cache.Cache { + return cache.New(time.Hour) +} + +func TestCachedCovidService_GetNationalCases(t *testing.T) { + t.Run("cache miss - calls underlying service", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCases").Return(expected, nil).Once() + + result, err := svc.GetNationalCases() + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertExpectations(t) + }) + + t.Run("cache hit - does not call underlying service again", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCases").Return(expected, nil).Once() + + _, _ = svc.GetNationalCases() // prime cache + result, err := svc.GetNationalCases() // should hit cache + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertNumberOfCalls(t, "GetNationalCases", 1) + }) + + t.Run("error - returns error from underlying service", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetNationalCases").Return([]models.NationalCase{}, errors.New("db error")) + + _, err := svc.GetNationalCases() + assert.Error(t, err) + }) +} + +func TestCachedCovidService_GetNationalCasesSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesSorted", sp).Return(expected, nil).Once() + + result, err := svc.GetNationalCasesSorted(sp) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // cache hit + result2, err := svc.GetNationalCasesSorted(sp) + assert.NoError(t, err) + assert.Equal(t, expected, result2) + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesSorted", 1) +} + +func TestCachedCovidService_GetNationalCasesSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "desc"} + mockSvc.On("GetNationalCasesSorted", sp).Return([]models.NationalCase{}, errors.New("err")) + _, err := svc.GetNationalCasesSorted(sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetNationalCasesPaginated(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesPaginated", 10, 0).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetNationalCasesPaginated(10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + // cache hit + _, _, _ = svc.GetNationalCasesPaginated(10, 0) + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesPaginated", 1) +} + +func TestCachedCovidService_GetNationalCasesPaginated_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetNationalCasesPaginated", 10, 0).Return([]models.NationalCase{}, 0, errors.New("err")) + _, _, err := svc.GetNationalCasesPaginated(10, 0) + assert.Error(t, err) +} + +func TestCachedCovidService_GetNationalCasesPaginatedSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesPaginatedSorted", 10, 0, sp).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetNationalCasesPaginatedSorted(10, 0, sp) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetNationalCasesPaginatedSorted(10, 0, sp) + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesPaginatedSorted", 1) +} + +func TestCachedCovidService_GetNationalCasesPaginatedSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetNationalCasesPaginatedSorted", 10, 0, sp).Return([]models.NationalCase{}, 0, errors.New("err")) + _, _, err := svc.GetNationalCasesPaginatedSorted(10, 0, sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetNationalCasesByDateRange(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesByDateRange", "2021-01-01", "2021-12-31").Return(expected, nil).Once() + + result, err := svc.GetNationalCasesByDateRange("2021-01-01", "2021-12-31") + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetNationalCasesByDateRange("2021-01-01", "2021-12-31") + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesByDateRange", 1) +} + +func TestCachedCovidService_GetNationalCasesByDateRange_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetNationalCasesByDateRange", "2021-01-01", "2021-12-31").Return([]models.NationalCase{}, errors.New("err")) + _, err := svc.GetNationalCasesByDateRange("2021-01-01", "2021-12-31") + assert.Error(t, err) +} + +func TestCachedCovidService_GetNationalCasesByDateRangeSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesByDateRangeSorted", "2021-01-01", "2021-12-31", sp).Return(expected, nil).Once() + + result, err := svc.GetNationalCasesByDateRangeSorted("2021-01-01", "2021-12-31", sp) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetNationalCasesByDateRangeSorted("2021-01-01", "2021-12-31", sp) + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesByDateRangeSorted", 1) +} + +func TestCachedCovidService_GetNationalCasesByDateRangeSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetNationalCasesByDateRangeSorted", "2021-01-01", "2021-12-31", sp).Return([]models.NationalCase{}, errors.New("err")) + _, err := svc.GetNationalCasesByDateRangeSorted("2021-01-01", "2021-12-31", sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetNationalCasesByDateRangePaginated(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesByDateRangePaginated", "2021-01-01", "2021-12-31", 10, 0).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetNationalCasesByDateRangePaginated("2021-01-01", "2021-12-31", 10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetNationalCasesByDateRangePaginated("2021-01-01", "2021-12-31", 10, 0) + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesByDateRangePaginated", 1) +} + +func TestCachedCovidService_GetNationalCasesByDateRangePaginated_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetNationalCasesByDateRangePaginated", "2021-01-01", "2021-12-31", 10, 0).Return([]models.NationalCase{}, 0, errors.New("err")) + _, _, err := svc.GetNationalCasesByDateRangePaginated("2021-01-01", "2021-12-31", 10, 0) + assert.Error(t, err) +} + +func TestCachedCovidService_GetNationalCasesByDateRangePaginatedSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.NationalCase{{}} + mockSvc.On("GetNationalCasesByDateRangePaginatedSorted", "2021-01-01", "2021-12-31", 10, 0, sp).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetNationalCasesByDateRangePaginatedSorted("2021-01-01", "2021-12-31", 10, 0, sp) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetNationalCasesByDateRangePaginatedSorted("2021-01-01", "2021-12-31", 10, 0, sp) + mockSvc.AssertNumberOfCalls(t, "GetNationalCasesByDateRangePaginatedSorted", 1) +} + +func TestCachedCovidService_GetNationalCasesByDateRangePaginatedSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetNationalCasesByDateRangePaginatedSorted", "2021-01-01", "2021-12-31", 10, 0, sp).Return([]models.NationalCase{}, 0, errors.New("err")) + _, _, err := svc.GetNationalCasesByDateRangePaginatedSorted("2021-01-01", "2021-12-31", 10, 0, sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetLatestNationalCase(t *testing.T) { + t.Run("success with cache", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := &models.NationalCase{} + mockSvc.On("GetLatestNationalCase").Return(expected, nil).Once() + + result, err := svc.GetLatestNationalCase() + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetLatestNationalCase() + mockSvc.AssertNumberOfCalls(t, "GetLatestNationalCase", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetLatestNationalCase").Return(nil, errors.New("err")) + _, err := svc.GetLatestNationalCase() + assert.Error(t, err) + }) +} + +func TestCachedCovidService_GetNationalCaseByDay(t *testing.T) { + t.Run("success with cache", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := &models.NationalCase{} + mockSvc.On("GetNationalCaseByDay", int64(1)).Return(expected, nil).Once() + + result, err := svc.GetNationalCaseByDay(1) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetNationalCaseByDay(1) + mockSvc.AssertNumberOfCalls(t, "GetNationalCaseByDay", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetNationalCaseByDay", int64(1)).Return(nil, errors.New("err")) + _, err := svc.GetNationalCaseByDay(1) + assert.Error(t, err) + }) +} + +func TestCachedCovidService_GetProvinces(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.Province{{}} + mockSvc.On("GetProvinces").Return(expected, nil).Once() + + result, err := svc.GetProvinces() + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvinces() + mockSvc.AssertNumberOfCalls(t, "GetProvinces", 1) +} + +func TestCachedCovidService_GetProvinces_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvinces").Return([]models.Province{}, errors.New("err")) + _, err := svc.GetProvinces() + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceByID(t *testing.T) { + t.Run("success with cache", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := &models.Province{} + mockSvc.On("GetProvinceByID", "1").Return(expected, nil).Once() + + result, err := svc.GetProvinceByID("1") + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvinceByID("1") + mockSvc.AssertNumberOfCalls(t, "GetProvinceByID", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvinceByID", "1").Return(nil, errors.New("err")) + _, err := svc.GetProvinceByID("1") + assert.Error(t, err) + }) +} + +func TestCachedCovidService_GetProvincesWithLatestCase(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceWithLatestCase{{}} + mockSvc.On("GetProvincesWithLatestCase").Return(expected, nil).Once() + + result, err := svc.GetProvincesWithLatestCase() + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvincesWithLatestCase() + mockSvc.AssertNumberOfCalls(t, "GetProvincesWithLatestCase", 1) +} + +func TestCachedCovidService_GetProvincesWithLatestCase_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvincesWithLatestCase").Return([]models.ProvinceWithLatestCase{}, errors.New("err")) + _, err := svc.GetProvincesWithLatestCase() + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCases(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCases", "p1").Return(expected, nil).Once() + + result, err := svc.GetProvinceCases("p1") + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvinceCases("p1") + mockSvc.AssertNumberOfCalls(t, "GetProvinceCases", 1) +} + +func TestCachedCovidService_GetProvinceCases_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvinceCases", "p1").Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetProvinceCases("p1") + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesSorted", "p1", sp).Return(expected, nil).Once() + + result, err := svc.GetProvinceCasesSorted("p1", sp) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvinceCasesSorted("p1", sp) + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesSorted", 1) +} + +func TestCachedCovidService_GetProvinceCasesSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetProvinceCasesSorted", "p1", sp).Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetProvinceCasesSorted("p1", sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesPaginated(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesPaginated", "p1", 10, 0).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetProvinceCasesPaginated("p1", 10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetProvinceCasesPaginated("p1", 10, 0) + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesPaginated", 1) +} + +func TestCachedCovidService_GetProvinceCasesPaginated_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvinceCasesPaginated", "p1", 10, 0).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetProvinceCasesPaginated("p1", 10, 0) + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesPaginatedSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesPaginatedSorted", "p1", 10, 0, sp).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetProvinceCasesPaginatedSorted("p1", 10, 0, sp) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetProvinceCasesPaginatedSorted("p1", 10, 0, sp) + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesPaginatedSorted", 1) +} + +func TestCachedCovidService_GetProvinceCasesPaginatedSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetProvinceCasesPaginatedSorted", "p1", 10, 0, sp).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetProvinceCasesPaginatedSorted("p1", 10, 0, sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesByDateRange(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesByDateRange", "p1", "2021-01-01", "2021-12-31").Return(expected, nil).Once() + + result, err := svc.GetProvinceCasesByDateRange("p1", "2021-01-01", "2021-12-31") + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvinceCasesByDateRange("p1", "2021-01-01", "2021-12-31") + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesByDateRange", 1) +} + +func TestCachedCovidService_GetProvinceCasesByDateRange_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvinceCasesByDateRange", "p1", "2021-01-01", "2021-12-31").Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetProvinceCasesByDateRange("p1", "2021-01-01", "2021-12-31") + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesByDateRangeSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesByDateRangeSorted", "p1", "2021-01-01", "2021-12-31", sp).Return(expected, nil).Once() + + result, err := svc.GetProvinceCasesByDateRangeSorted("p1", "2021-01-01", "2021-12-31", sp) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetProvinceCasesByDateRangeSorted("p1", "2021-01-01", "2021-12-31", sp) + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesByDateRangeSorted", 1) +} + +func TestCachedCovidService_GetProvinceCasesByDateRangeSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetProvinceCasesByDateRangeSorted", "p1", "2021-01-01", "2021-12-31", sp).Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetProvinceCasesByDateRangeSorted("p1", "2021-01-01", "2021-12-31", sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesByDateRangePaginated(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesByDateRangePaginated", "p1", "2021-01-01", "2021-12-31", 10, 0).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetProvinceCasesByDateRangePaginated("p1", "2021-01-01", "2021-12-31", 10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetProvinceCasesByDateRangePaginated("p1", "2021-01-01", "2021-12-31", 10, 0) + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesByDateRangePaginated", 1) +} + +func TestCachedCovidService_GetProvinceCasesByDateRangePaginated_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetProvinceCasesByDateRangePaginated", "p1", "2021-01-01", "2021-12-31", 10, 0).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetProvinceCasesByDateRangePaginated("p1", "2021-01-01", "2021-12-31", 10, 0) + assert.Error(t, err) +} + +func TestCachedCovidService_GetProvinceCasesByDateRangePaginatedSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetProvinceCasesByDateRangePaginatedSorted", "p1", "2021-01-01", "2021-12-31", 10, 0, sp).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetProvinceCasesByDateRangePaginatedSorted("p1", "2021-01-01", "2021-12-31", 10, 0, sp) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetProvinceCasesByDateRangePaginatedSorted("p1", "2021-01-01", "2021-12-31", 10, 0, sp) + mockSvc.AssertNumberOfCalls(t, "GetProvinceCasesByDateRangePaginatedSorted", 1) +} + +func TestCachedCovidService_GetProvinceCasesByDateRangePaginatedSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetProvinceCasesByDateRangePaginatedSorted", "p1", "2021-01-01", "2021-12-31", 10, 0, sp).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetProvinceCasesByDateRangePaginatedSorted("p1", "2021-01-01", "2021-12-31", 10, 0, sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCases(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCases").Return(expected, nil).Once() + + result, err := svc.GetAllProvinceCases() + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetAllProvinceCases() + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCases", 1) +} + +func TestCachedCovidService_GetAllProvinceCases_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetAllProvinceCases").Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetAllProvinceCases() + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesSorted", sp).Return(expected, nil).Once() + + result, err := svc.GetAllProvinceCasesSorted(sp) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetAllProvinceCasesSorted(sp) + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesSorted", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetAllProvinceCasesSorted", sp).Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetAllProvinceCasesSorted(sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesPaginated(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesPaginated", 10, 0).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetAllProvinceCasesPaginated(10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetAllProvinceCasesPaginated(10, 0) + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesPaginated", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesPaginated_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetAllProvinceCasesPaginated", 10, 0).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetAllProvinceCasesPaginated(10, 0) + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesPaginatedSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesPaginatedSorted", 10, 0, sp).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetAllProvinceCasesPaginatedSorted(10, 0, sp) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetAllProvinceCasesPaginatedSorted(10, 0, sp) + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesPaginatedSorted", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesPaginatedSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetAllProvinceCasesPaginatedSorted", 10, 0, sp).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetAllProvinceCasesPaginatedSorted(10, 0, sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRange(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesByDateRange", "2021-01-01", "2021-12-31").Return(expected, nil).Once() + + result, err := svc.GetAllProvinceCasesByDateRange("2021-01-01", "2021-12-31") + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetAllProvinceCasesByDateRange("2021-01-01", "2021-12-31") + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesByDateRange", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRange_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetAllProvinceCasesByDateRange", "2021-01-01", "2021-12-31").Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetAllProvinceCasesByDateRange("2021-01-01", "2021-12-31") + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRangeSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesByDateRangeSorted", "2021-01-01", "2021-12-31", sp).Return(expected, nil).Once() + + result, err := svc.GetAllProvinceCasesByDateRangeSorted("2021-01-01", "2021-12-31", sp) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + _, _ = svc.GetAllProvinceCasesByDateRangeSorted("2021-01-01", "2021-12-31", sp) + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesByDateRangeSorted", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRangeSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetAllProvinceCasesByDateRangeSorted", "2021-01-01", "2021-12-31", sp).Return([]models.ProvinceCaseWithDate{}, errors.New("err")) + _, err := svc.GetAllProvinceCasesByDateRangeSorted("2021-01-01", "2021-12-31", sp) + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRangePaginated(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesByDateRangePaginated", "2021-01-01", "2021-12-31", 10, 0).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetAllProvinceCasesByDateRangePaginated("2021-01-01", "2021-12-31", 10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetAllProvinceCasesByDateRangePaginated("2021-01-01", "2021-12-31", 10, 0) + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesByDateRangePaginated", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRangePaginated_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + mockSvc.On("GetAllProvinceCasesByDateRangePaginated", "2021-01-01", "2021-12-31", 10, 0).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetAllProvinceCasesByDateRangePaginated("2021-01-01", "2021-12-31", 10, 0) + assert.Error(t, err) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRangePaginatedSorted(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + expected := []models.ProvinceCaseWithDate{{}} + mockSvc.On("GetAllProvinceCasesByDateRangePaginatedSorted", "2021-01-01", "2021-12-31", 10, 0, sp).Return(expected, 1, nil).Once() + + cases, total, err := svc.GetAllProvinceCasesByDateRangePaginatedSorted("2021-01-01", "2021-12-31", 10, 0, sp) + assert.NoError(t, err) + assert.Equal(t, expected, cases) + assert.Equal(t, 1, total) + + _, _, _ = svc.GetAllProvinceCasesByDateRangePaginatedSorted("2021-01-01", "2021-12-31", 10, 0, sp) + mockSvc.AssertNumberOfCalls(t, "GetAllProvinceCasesByDateRangePaginatedSorted", 1) +} + +func TestCachedCovidService_GetAllProvinceCasesByDateRangePaginatedSorted_Error(t *testing.T) { + mockSvc := new(MockCovidService) + c := newTestCache() + svc := NewCachedCovidService(mockSvc, c) + + sp := utils.SortParams{Field: "date", Order: "asc"} + mockSvc.On("GetAllProvinceCasesByDateRangePaginatedSorted", "2021-01-01", "2021-12-31", 10, 0, sp).Return([]models.ProvinceCaseWithDate{}, 0, errors.New("err")) + _, _, err := svc.GetAllProvinceCasesByDateRangePaginatedSorted("2021-01-01", "2021-12-31", 10, 0, sp) + assert.Error(t, err) +} diff --git a/internal/service/cached_regency_service.go b/internal/service/cached_regency_service.go new file mode 100644 index 0000000..b8d3372 --- /dev/null +++ b/internal/service/cached_regency_service.go @@ -0,0 +1,89 @@ +package service + +import ( + "fmt" + + "github.com/banua-coder/pico-api-go/internal/models" + "github.com/banua-coder/pico-api-go/pkg/cache" +) + +// cachedRegencyService wraps a RegencyServiceInterface with in-memory caching. +type cachedRegencyService struct { + svc RegencyServiceInterface + cache *cache.Cache +} + +// NewCachedRegencyService returns a RegencyServiceInterface backed by an in-memory cache. +func NewCachedRegencyService(svc RegencyServiceInterface, c *cache.Cache) RegencyServiceInterface { + return &cachedRegencyService{svc: svc, cache: c} +} + +func (s *cachedRegencyService) GetRegencies() ([]models.Regency, error) { + const key = "regency:all" + if v, ok := s.cache.Get(key); ok { + return v.([]models.Regency), nil + } + result, err := s.svc.GetRegencies() + if err != nil { + return nil, err + } + s.cache.Set(key, result, ttlDefault) + return result, nil +} + +func (s *cachedRegencyService) GetRegenciesPaginated(limit, offset int) ([]models.Regency, int, error) { + key := fmt.Sprintf("regency:all:page:%d:%d", limit, offset) + type res struct { + items []models.Regency + total int + } + if v, ok := s.cache.Get(key); ok { + r := v.(res) + return r.items, r.total, nil + } + items, total, err := s.svc.GetRegenciesPaginated(limit, offset) + if err != nil { + return nil, 0, err + } + s.cache.Set(key, res{items, total}, ttlDefault) + return items, total, nil +} + +func (s *cachedRegencyService) GetRegencyByID(id int) (*models.Regency, error) { + key := fmt.Sprintf("regency:%d", id) + if v, ok := s.cache.Get(key); ok { + return v.(*models.Regency), nil + } + result, err := s.svc.GetRegencyByID(id) + if err != nil { + return nil, err + } + s.cache.Set(key, result, ttlDefault) + return result, nil +} + +func (s *cachedRegencyService) GetRegencyCases(regencyID int) ([]models.RegencyCase, error) { + key := fmt.Sprintf("regency:%d:cases:all", regencyID) + if v, ok := s.cache.Get(key); ok { + return v.([]models.RegencyCase), nil + } + result, err := s.svc.GetRegencyCases(regencyID) + if err != nil { + return nil, err + } + s.cache.Set(key, result, ttlDefault) + return result, nil +} + +func (s *cachedRegencyService) GetLatestRegencyCases() ([]models.RegencyCase, error) { + const key = "regency:cases:latest" + if v, ok := s.cache.Get(key); ok { + return v.([]models.RegencyCase), nil + } + result, err := s.svc.GetLatestRegencyCases() + if err != nil { + return nil, err + } + s.cache.Set(key, result, ttlLatest) + return result, nil +} diff --git a/internal/service/cached_regency_service_test.go b/internal/service/cached_regency_service_test.go new file mode 100644 index 0000000..47db658 --- /dev/null +++ b/internal/service/cached_regency_service_test.go @@ -0,0 +1,249 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/banua-coder/pico-api-go/internal/models" + "github.com/banua-coder/pico-api-go/pkg/cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockRegencyService mocks RegencyServiceInterface +type MockRegencyService struct { + mock.Mock +} + +func (m *MockRegencyService) GetRegencies() ([]models.Regency, error) { + args := m.Called() + return args.Get(0).([]models.Regency), args.Error(1) +} + +func (m *MockRegencyService) GetRegenciesPaginated(limit, offset int) ([]models.Regency, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.Regency), args.Int(1), args.Error(2) +} + +func (m *MockRegencyService) GetRegencyByID(id int) (*models.Regency, error) { + args := m.Called(id) + res := args.Get(0) + if res == nil { + return nil, args.Error(1) + } + return res.(*models.Regency), args.Error(1) +} + +func (m *MockRegencyService) GetRegencyCases(regencyID int) ([]models.RegencyCase, error) { + args := m.Called(regencyID) + return args.Get(0).([]models.RegencyCase), args.Error(1) +} + +func (m *MockRegencyService) GetLatestRegencyCases() ([]models.RegencyCase, error) { + args := m.Called() + return args.Get(0).([]models.RegencyCase), args.Error(1) +} + +func TestCachedRegencyService_GetRegencies(t *testing.T) { + t.Run("cache miss - calls underlying service", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.Regency{{}} + mockSvc.On("GetRegencies").Return(expected, nil).Once() + + result, err := svc.GetRegencies() + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertExpectations(t) + }) + + t.Run("cache hit - does not call underlying service again", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.Regency{{}} + mockSvc.On("GetRegencies").Return(expected, nil).Once() + + _, _ = svc.GetRegencies() + result, err := svc.GetRegencies() + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertNumberOfCalls(t, "GetRegencies", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + mockSvc.On("GetRegencies").Return([]models.Regency{}, errors.New("db error")) + _, err := svc.GetRegencies() + assert.Error(t, err) + }) +} + +func TestCachedRegencyService_GetRegenciesPaginated(t *testing.T) { + t.Run("cache miss", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.Regency{{}} + mockSvc.On("GetRegenciesPaginated", 10, 0).Return(expected, 1, nil).Once() + + items, total, err := svc.GetRegenciesPaginated(10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, items) + assert.Equal(t, 1, total) + }) + + t.Run("cache hit", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.Regency{{}} + mockSvc.On("GetRegenciesPaginated", 10, 0).Return(expected, 1, nil).Once() + + _, _, _ = svc.GetRegenciesPaginated(10, 0) + items, total, err := svc.GetRegenciesPaginated(10, 0) + assert.NoError(t, err) + assert.Equal(t, expected, items) + assert.Equal(t, 1, total) + mockSvc.AssertNumberOfCalls(t, "GetRegenciesPaginated", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + mockSvc.On("GetRegenciesPaginated", 10, 0).Return([]models.Regency{}, 0, errors.New("err")) + _, _, err := svc.GetRegenciesPaginated(10, 0) + assert.Error(t, err) + }) +} + +func TestCachedRegencyService_GetRegencyByID(t *testing.T) { + t.Run("cache miss", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := &models.Regency{} + mockSvc.On("GetRegencyByID", 1).Return(expected, nil).Once() + + result, err := svc.GetRegencyByID(1) + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("cache hit", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := &models.Regency{} + mockSvc.On("GetRegencyByID", 1).Return(expected, nil).Once() + + _, _ = svc.GetRegencyByID(1) + result, err := svc.GetRegencyByID(1) + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertNumberOfCalls(t, "GetRegencyByID", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + mockSvc.On("GetRegencyByID", 1).Return(nil, errors.New("not found")) + _, err := svc.GetRegencyByID(1) + assert.Error(t, err) + }) +} + +func TestCachedRegencyService_GetRegencyCases(t *testing.T) { + t.Run("cache miss", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.RegencyCase{{}} + mockSvc.On("GetRegencyCases", 1).Return(expected, nil).Once() + + result, err := svc.GetRegencyCases(1) + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("cache hit", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.RegencyCase{{}} + mockSvc.On("GetRegencyCases", 1).Return(expected, nil).Once() + + _, _ = svc.GetRegencyCases(1) + result, err := svc.GetRegencyCases(1) + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertNumberOfCalls(t, "GetRegencyCases", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + mockSvc.On("GetRegencyCases", 1).Return([]models.RegencyCase{}, errors.New("err")) + _, err := svc.GetRegencyCases(1) + assert.Error(t, err) + }) +} + +func TestCachedRegencyService_GetLatestRegencyCases(t *testing.T) { + t.Run("cache miss", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.RegencyCase{{}} + mockSvc.On("GetLatestRegencyCases").Return(expected, nil).Once() + + result, err := svc.GetLatestRegencyCases() + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("cache hit", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + expected := []models.RegencyCase{{}} + mockSvc.On("GetLatestRegencyCases").Return(expected, nil).Once() + + _, _ = svc.GetLatestRegencyCases() + result, err := svc.GetLatestRegencyCases() + assert.NoError(t, err) + assert.Equal(t, expected, result) + mockSvc.AssertNumberOfCalls(t, "GetLatestRegencyCases", 1) + }) + + t.Run("error", func(t *testing.T) { + mockSvc := new(MockRegencyService) + c := cache.New(time.Hour) + svc := NewCachedRegencyService(mockSvc, c) + + mockSvc.On("GetLatestRegencyCases").Return([]models.RegencyCase{}, errors.New("err")) + _, err := svc.GetLatestRegencyCases() + assert.Error(t, err) + }) +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..b8c33fc --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,92 @@ +package cache + +import ( + "strings" + "sync" + "time" +) + +type entry struct { + value interface{} + expiresAt time.Time +} + +// Cache is a thread-safe in-memory cache with TTL support. +type Cache struct { + mu sync.RWMutex + items map[string]entry + defaultTTL time.Duration +} + +// New creates a new Cache with the given default TTL. +func New(defaultTTL time.Duration) *Cache { + return &Cache{ + items: make(map[string]entry), + defaultTTL: defaultTTL, + } +} + +// Set stores a value in the cache. An optional TTL overrides the default. +func (c *Cache) Set(key string, value interface{}, ttl ...time.Duration) { + d := c.defaultTTL + if len(ttl) > 0 && ttl[0] > 0 { + d = ttl[0] + } + c.mu.Lock() + c.items[key] = entry{value: value, expiresAt: time.Now().Add(d)} + c.mu.Unlock() +} + +// Get retrieves a value from the cache. Returns (value, true) on hit, (nil, false) on miss or expiry. +func (c *Cache) Get(key string) (interface{}, bool) { + c.mu.RLock() + e, ok := c.items[key] + c.mu.RUnlock() + if !ok || time.Now().After(e.expiresAt) { + return nil, false + } + return e.value, true +} + +// Delete removes a single key from the cache. +func (c *Cache) Delete(key string) { + c.mu.Lock() + delete(c.items, key) + c.mu.Unlock() +} + +// DeletePrefix removes all keys that start with the given prefix. +func (c *Cache) DeletePrefix(prefix string) { + c.mu.Lock() + for k := range c.items { + if strings.HasPrefix(k, prefix) { + delete(c.items, k) + } + } + c.mu.Unlock() +} + +// Clear removes all entries from the cache. +func (c *Cache) Clear() { + c.mu.Lock() + c.items = make(map[string]entry) + c.mu.Unlock() +} + +// StartCleanup launches a background goroutine that removes expired entries at the given interval. +func (c *Cache) StartCleanup(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + c.mu.Lock() + for k, e := range c.items { + if now.After(e.expiresAt) { + delete(c.items, k) + } + } + c.mu.Unlock() + } + }() +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 0000000..428dc14 --- /dev/null +++ b/pkg/cache/cache_test.go @@ -0,0 +1,98 @@ +package cache + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSetGet(t *testing.T) { + c := New(time.Minute) + c.Set("foo", "bar") + v, ok := c.Get("foo") + assert.True(t, ok) + assert.Equal(t, "bar", v) +} + +func TestGet_Miss(t *testing.T) { + c := New(time.Minute) + _, ok := c.Get("missing") + assert.False(t, ok) +} + +func TestTTLExpiry(t *testing.T) { + c := New(time.Millisecond * 50) + c.Set("k", "v") + time.Sleep(time.Millisecond * 100) + _, ok := c.Get("k") + assert.False(t, ok, "expected expired entry to be missing") +} + +func TestCustomTTL(t *testing.T) { + c := New(time.Hour) + c.Set("k", "v", time.Millisecond*50) + time.Sleep(time.Millisecond * 100) + _, ok := c.Get("k") + assert.False(t, ok, "expected custom TTL to expire") +} + +func TestDelete(t *testing.T) { + c := New(time.Minute) + c.Set("k", "v") + c.Delete("k") + _, ok := c.Get("k") + assert.False(t, ok) +} + +func TestDeletePrefix(t *testing.T) { + c := New(time.Minute) + c.Set("national:all", 1) + c.Set("national:latest", 2) + c.Set("province:all", 3) + c.DeletePrefix("national:") + _, ok1 := c.Get("national:all") + _, ok2 := c.Get("national:latest") + _, ok3 := c.Get("province:all") + assert.False(t, ok1) + assert.False(t, ok2) + assert.True(t, ok3) +} + +func TestClear(t *testing.T) { + c := New(time.Minute) + c.Set("a", 1) + c.Set("b", 2) + c.Clear() + _, ok1 := c.Get("a") + _, ok2 := c.Get("b") + assert.False(t, ok1) + assert.False(t, ok2) +} + +func TestStartCleanup(t *testing.T) { + c := New(time.Millisecond * 50) + c.Set("k", "v") + c.StartCleanup(time.Millisecond * 30) + time.Sleep(time.Millisecond * 200) + c.mu.RLock() + _, exists := c.items["k"] + c.mu.RUnlock() + assert.False(t, exists, "cleanup goroutine should have evicted expired entry") +} + +func TestConcurrentAccess(t *testing.T) { + c := New(time.Minute) + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + key := "k" + c.Set(key, i) + c.Get(key) + }(i) + } + wg.Wait() +} diff --git a/pkg/cache/redis.go b/pkg/cache/redis.go new file mode 100644 index 0000000..325fbcc --- /dev/null +++ b/pkg/cache/redis.go @@ -0,0 +1,189 @@ +package cache + +import ( + "context" + "encoding/json" + "log" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// RedisCache is a Redis-backed distributed cache with the same interface as Cache. +// It stores values as JSON and supports TTL, prefix deletion, and full clear. +type RedisCache struct { + client *redis.Client + defaultTTL time.Duration +} + +// RedisOptions holds Redis connection options. +type RedisOptions struct { + Addr string + Password string + DB int +} + +// NewRedis creates a new RedisCache. Call Ping to verify connectivity before use. +func NewRedis(opts RedisOptions, defaultTTL time.Duration) *RedisCache { + client := redis.NewClient(&redis.Options{ + Addr: opts.Addr, + Password: opts.Password, + DB: opts.DB, + }) + return &RedisCache{client: client, defaultTTL: defaultTTL} +} + +// Ping verifies connectivity to Redis. +func (r *RedisCache) Ping(ctx context.Context) error { + return r.client.Ping(ctx).Err() +} + +// Set stores a value (JSON-encoded) in Redis. An optional TTL overrides the default. +func (r *RedisCache) Set(key string, value interface{}, ttl ...time.Duration) { + d := r.defaultTTL + if len(ttl) > 0 && ttl[0] > 0 { + d = ttl[0] + } + b, err := json.Marshal(value) + if err != nil { + log.Printf("cache/redis: marshal error for key %s: %v", key, err) + return + } + if err := r.client.Set(context.Background(), key, b, d).Err(); err != nil { + log.Printf("cache/redis: set error for key %s: %v", key, err) + } +} + +// Get retrieves and JSON-decodes a value. Returns (value, true) on hit, (nil, false) on miss. +// The caller must type-assert carefully — returned value is map[string]interface{} or []interface{} +// for complex types. Use GetInto for typed retrieval. +func (r *RedisCache) Get(key string) (interface{}, bool) { + raw, err := r.client.Get(context.Background(), key).Bytes() + if err != nil { + return nil, false + } + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + return nil, false + } + return v, true +} + +// GetInto retrieves a value from Redis and unmarshals it into dest. +func (r *RedisCache) GetInto(key string, dest interface{}) bool { + raw, err := r.client.Get(context.Background(), key).Bytes() + if err != nil { + return false + } + return json.Unmarshal(raw, dest) == nil +} + +// Delete removes a single key. +func (r *RedisCache) Delete(key string) { + r.client.Del(context.Background(), key) +} + +// DeletePrefix removes all keys matching the given prefix using SCAN. +func (r *RedisCache) DeletePrefix(prefix string) { + ctx := context.Background() + var cursor uint64 + for { + keys, next, err := r.client.Scan(ctx, cursor, prefix+"*", 100).Result() + if err != nil { + log.Printf("cache/redis: scan error: %v", err) + return + } + if len(keys) > 0 { + r.client.Del(ctx, keys...) + } + cursor = next + if cursor == 0 { + break + } + } +} + +// Clear removes all keys in the current DB. Use with caution in shared Redis instances. +func (r *RedisCache) Clear() { + if err := r.client.FlushDB(context.Background()).Err(); err != nil { + log.Printf("cache/redis: flushdb error: %v", err) + } +} + +// StartCleanup is a no-op for Redis (TTL is handled natively by Redis). +func (r *RedisCache) StartCleanup(_ time.Duration) {} + +// Ensure RedisCache satisfies the same duck-type interface as Cache. +var _ interface { + Set(string, interface{}, ...time.Duration) + Get(string) (interface{}, bool) + Delete(string) + DeletePrefix(string) + Clear() + StartCleanup(time.Duration) +} = (*RedisCache)(nil) + +// NOTE: RedisCache.Get returns generic interface{} (JSON-decoded). +// For typed access in service decorators, use GetInto. +// The existing cachedCovidService / cachedRegencyService use type assertions after Get, +// which works for in-memory cache but NOT for Redis (JSON round-trip loses concrete types). +// Use NewRedisAwareCache (below) as a drop-in *Cache replacement that backs in-memory with Redis. + +// RedisAwareCache wraps the in-memory Cache and synchronises with Redis for distributed +// invalidation and warm-up. On Get miss, it tries Redis. On Set, it writes to both. +// This keeps the type-safe interface of *Cache while adding Redis persistence. +type RedisAwareCache struct { + mem *Cache + redis *RedisCache +} + +// NewRedisAwareCache creates a dual-layer cache backed by in-memory + Redis. +func NewRedisAwareCache(defaultTTL time.Duration, redisOpts RedisOptions) (*RedisAwareCache, error) { + rc := NewRedis(redisOpts, defaultTTL) + if err := rc.Ping(context.Background()); err != nil { + return nil, err + } + return &RedisAwareCache{ + mem: New(defaultTTL), + redis: rc, + }, nil +} + +func (c *RedisAwareCache) Set(key string, value interface{}, ttl ...time.Duration) { + c.mem.Set(key, value, ttl...) + c.redis.Set(key, value, ttl...) +} + +func (c *RedisAwareCache) Get(key string) (interface{}, bool) { + return c.mem.Get(key) +} + +func (c *RedisAwareCache) Delete(key string) { + c.mem.Delete(key) + c.redis.Delete(key) +} + +func (c *RedisAwareCache) DeletePrefix(prefix string) { + c.mem.DeletePrefix(prefix) + c.redis.DeletePrefix(prefix) +} + +func (c *RedisAwareCache) Clear() { + c.mem.Clear() + c.redis.Clear() +} + +func (c *RedisAwareCache) StartCleanup(interval time.Duration) { + c.mem.StartCleanup(interval) +} + +// Unwrap returns the underlying *Cache for compatibility with functions that require *Cache directly. +func (c *RedisAwareCache) Unwrap() *Cache { + return c.mem +} + +// IsPrefixMatch is a utility used by DeletePrefix. +func IsPrefixMatch(key, prefix string) bool { + return strings.HasPrefix(key, prefix) +}